Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ export const makeServerLayer = Layer.unwrap(
const server = yield* HttpServer.HttpServer;
const address = server.address;
if (typeof address === "string" || !("port" in address)) {
return;
return null;
}

const state = yield* makePersistedServerRuntimeState({
Expand All @@ -510,13 +510,16 @@ export const makeServerLayer = Layer.unwrap(
Effect.logWarning("Failed to persist server runtime state", { cause }),
),
);
return state;
}),
() =>
clearPersistedServerRuntimeState(config.serverRuntimeStatePath).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Failed to clear server runtime state", { cause }),
),
),
(state) =>
state === null
? Effect.void
: clearPersistedServerRuntimeState(config.serverRuntimeStatePath, state).pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/server.ts:518

The old server can delete a newer server's runtime-state file: clearPersistedServerRuntimeState verifies ownership with readPersistedServerRuntimeState, then performs fs.remove separately, allowing an overlapping startup to replace the file between those operations. This removes the active endpoint record and breaks discovery; the check-and-remove must be atomic (or otherwise revalidate ownership immediately before deletion).

Also found in 1 other location(s)

apps/server/src/serverRuntimeState.ts:96

The owner check is a time-of-check/time-of-use race: after readPersistedServerRuntimeState(path) confirms that the old instance owns the file, a newer server can atomically replace the state before fs.remove(path) runs. The old shutdown then unlinks the newer server's record, recreating the endpoint-discovery failure this guard is intended to prevent.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/server.ts around line 518:

The old server can delete a newer server's runtime-state file: `clearPersistedServerRuntimeState` verifies ownership with `readPersistedServerRuntimeState`, then performs `fs.remove` separately, allowing an overlapping startup to replace the file between those operations. This removes the active endpoint record and breaks discovery; the check-and-remove must be atomic (or otherwise revalidate ownership immediately before deletion).

Also found in 1 other location(s):
- apps/server/src/serverRuntimeState.ts:96 -- The owner check is a time-of-check/time-of-use race: after `readPersistedServerRuntimeState(path)` confirms that the old instance owns the file, a newer server can atomically replace the state before `fs.remove(path)` runs. The old shutdown then unlinks the newer server's record, recreating the endpoint-discovery failure this guard is intended to prevent.

Effect.catchCause((cause) =>
Effect.logWarning("Failed to clear server runtime state", { cause }),
),
),
),
);
const tailscaleServeLayer = config.tailscaleServeEnabled
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/serverRuntimeState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,64 @@ describe("serverRuntimeState", () => {
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("clears runtime state owned by the stopping server", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-server-runtime-state-test-",
});
const statePath = path.join(root, "server.json");
const state: ServerRuntimeState.PersistedServerRuntimeState = {
version: 1,
pid: 123,
port: 4_971,
origin: "http://127.0.0.1:4971",
startedAt: "2026-06-20T00:00:00.000Z",
};
yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, state });

yield* ServerRuntimeState.clearPersistedServerRuntimeState(statePath, state);

const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath);
assert.isTrue(Option.isNone(restored));
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("does not clear runtime state replaced by a newer server", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-server-runtime-state-test-",
});
const statePath = path.join(root, "server.json");
const oldState: ServerRuntimeState.PersistedServerRuntimeState = {
version: 1,
pid: 123,
port: 4_971,
origin: "http://127.0.0.1:4971",
startedAt: "2026-06-20T00:00:00.000Z",
};
const currentState: ServerRuntimeState.PersistedServerRuntimeState = {
...oldState,
pid: 456,
port: 4_972,
origin: "http://127.0.0.1:4972",
startedAt: "2026-06-20T00:01:00.000Z",
};
yield* ServerRuntimeState.persistServerRuntimeState({
path: statePath,
state: currentState,
});

yield* ServerRuntimeState.clearPersistedServerRuntimeState(statePath, oldState);

const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath);
assert.deepEqual(Option.getOrThrow(restored), currentState);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("preserves malformed state decode failures", () => {
const logs: CapturedLog[] = [];
const logger = Logger.make(({ fiber, message }) => {
Expand Down
18 changes: 15 additions & 3 deletions apps/server/src/serverRuntimeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,19 @@ export const persistServerRuntimeState = (input: {
),
);

export const clearPersistedServerRuntimeState = (path: string) =>
Effect.gen(function* () {
export const clearPersistedServerRuntimeState = Effect.fn("clearPersistedServerRuntimeState")(
function* (path: string, expectedOwner?: Pick<PersistedServerRuntimeState, "pid" | "startedAt">) {
if (expectedOwner) {
const current = yield* readPersistedServerRuntimeState(path);
if (
Option.isNone(current) ||
current.value.pid !== expectedOwner.pid ||
current.value.startedAt !== expectedOwner.startedAt
) {
return;
}
}

const fs = yield* FileSystem.FileSystem;
yield* fs.remove(path, { force: true }).pipe(
Effect.mapError(
Expand All @@ -102,7 +113,8 @@ export const clearPersistedServerRuntimeState = (path: string) =>
),
}),
);
});
},
);

export const readPersistedServerRuntimeState = (path: string) =>
Effect.gen(function* () {
Expand Down
Loading