Skip to content

Commit a1fa595

Browse files
committed
fix(server): ignore own pid in startup preflight
1 parent cfafb4b commit a1fa595

3 files changed

Lines changed: 98 additions & 7 deletions

File tree

‎apps/server/src/cli/app.test.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// @effect-diagnostics nodeBuiltinImport:off -- The integration fixture binds the same platform socket or named pipe as the CLI.
2+
import * as NodeChildProcess from "node:child_process";
3+
import * as NodeEvents from "node:events";
24
import * as NodeFSP from "node:fs/promises";
35
import * as NodeNet from "node:net";
46
import * as NodeOS from "node:os";
57
import * as NodePath from "node:path";
68

79
import * as NodeServices from "@effect/platform-node/NodeServices";
8-
import { it } from "@effect/vitest";
10+
import { assert, it } from "@effect/vitest";
911
import type { DesktopAppActivationRequest } from "@t3tools/contracts";
1012
import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
1113
import * as HostProcess from "@t3tools/shared/HostProcess";
@@ -20,6 +22,22 @@ import { describe, expect } from "vite-plus/test";
2022
import { makeCli } from "../binCli.ts";
2123
import { PersistedServerRuntimeState } from "../serverRuntimeState.ts";
2224

25+
const liveProcess = Effect.acquireRelease(
26+
Effect.promise(async () => {
27+
const child = NodeChildProcess.spawn(process.execPath, ["-e", "process.stdin.resume()"], {
28+
stdio: ["pipe", "ignore", "ignore"],
29+
});
30+
await NodeEvents.EventEmitter.once(child, "spawn");
31+
return child;
32+
}),
33+
(child) =>
34+
Effect.promise(async () => {
35+
const exited = NodeEvents.EventEmitter.once(child, "exit");
36+
child.kill();
37+
await exited;
38+
}),
39+
);
40+
2341
const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState));
2442

2543
const runCli = (args: ReadonlyArray<string>, env: Record<string, string> = {}) =>
@@ -165,9 +183,11 @@ describe("t3 server command safety", () => {
165183
const baseDir = NodePath.join(root, "home");
166184
const stateDir = NodePath.join(baseDir, "userdata");
167185
const statePath = NodePath.join(stateDir, "server-runtime.json");
186+
const child = yield* liveProcess;
187+
assert(child.pid !== undefined);
168188
const record = yield* encodeRuntimeState({
169189
version: 1,
170-
pid: process.pid,
190+
pid: child.pid,
171191
port: 3773,
172192
origin: "http://127.0.0.1:3773",
173193
startedAt: "2026-10-01T00:00:00.000Z",

‎apps/server/src/cli/config.test.ts‎

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// @effect-diagnostics nodeBuiltinImport:off
2+
import * as NodeChildProcess from "node:child_process";
3+
import * as NodeEvents from "node:events";
24
import * as NodeFS from "node:fs";
35
import * as NodeOS from "node:os";
46

@@ -10,6 +12,7 @@ import * as Layer from "effect/Layer";
1012
import * as Option from "effect/Option";
1113
import * as Path from "effect/Path";
1214
import * as Redacted from "effect/Redacted";
15+
import * as Result from "effect/Result";
1316
import * as Schema from "effect/Schema";
1417

1518
import {
@@ -58,6 +61,22 @@ const makeDesktopBootstrap = (
5861
...overrides,
5962
});
6063

64+
const liveProcess = Effect.acquireRelease(
65+
Effect.promise(async () => {
66+
const child = NodeChildProcess.spawn(process.execPath, ["-e", "process.stdin.resume()"], {
67+
stdio: ["pipe", "ignore", "ignore"],
68+
});
69+
await NodeEvents.EventEmitter.once(child, "spawn");
70+
return child;
71+
}),
72+
(child) =>
73+
Effect.promise(async () => {
74+
const exited = NodeEvents.EventEmitter.once(child, "exit");
75+
child.kill();
76+
await exited;
77+
}),
78+
);
79+
6180
it.layer(NodeServices.layer)("cli config resolution", (it) => {
6281
const defaultObservabilityConfig = {
6382
traceMinLevel: "Info",
@@ -100,10 +119,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
100119
const fs = yield* FileSystem.FileSystem;
101120
const path = yield* Path.Path;
102121
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-preflight-" });
122+
const child = yield* liveProcess;
123+
assert(child.pid !== undefined);
103124
for (const [name, pid, mode, rejectRunningServer] of [
104125
["stale", 2_147_483_647, "web", true],
105-
["desktop", process.pid, "desktop", true],
106-
["serve", process.pid, "web", false],
126+
["own-pid", process.pid, "web", true],
127+
["desktop", child.pid, "desktop", true],
128+
["serve", child.pid, "web", false],
107129
] as const) {
108130
const baseDir = path.join(root, name);
109131
const stateDir = path.join(baseDir, "userdata");
@@ -119,7 +141,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
119141
}),
120142
);
121143
const cwd = path.join(root, `${name}-project`);
122-
const config = yield* resolveServerConfig(
144+
const configResult = yield* resolveServerConfig(
123145
{
124146
...minimalWebFlags(baseDir),
125147
mode: Option.some(mode),
@@ -135,13 +157,56 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
135157
ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
136158
),
137159
),
160+
Effect.result,
138161
);
139-
expect(config.cwd).toBe(cwd);
162+
assert(Result.isSuccess(configResult));
163+
expect(configResult.success.cwd).toBe(cwd);
140164
expect(yield* fs.exists(cwd)).toBe(true);
141165
}
142166
}),
143167
);
144168

169+
it.effect(
170+
"refuses a manual web launch when a runtime record belongs to another live process",
171+
() =>
172+
Effect.gen(function* () {
173+
const fs = yield* FileSystem.FileSystem;
174+
const path = yield* Path.Path;
175+
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-preflight-live-" });
176+
const child = yield* liveProcess;
177+
assert(child.pid !== undefined);
178+
const stateDir = path.join(baseDir, "userdata");
179+
yield* fs.makeDirectory(stateDir, { recursive: true });
180+
yield* fs.writeFileString(
181+
path.join(stateDir, "server-runtime.json"),
182+
yield* encodeUnknownJson({
183+
version: 1,
184+
pid: child.pid,
185+
port: 3773,
186+
origin: "http://127.0.0.1:3773",
187+
startedAt: "2026-10-01T00:00:00.000Z",
188+
}),
189+
);
190+
const cwd = path.join(baseDir, "project");
191+
const error = yield* resolveServerConfig(
192+
{ ...minimalWebFlags(baseDir), cwd: Option.some(cwd) },
193+
Option.none(),
194+
{ rejectRunningServer: true },
195+
).pipe(
196+
Effect.provide(
197+
Layer.merge(
198+
NetService.layer,
199+
ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
200+
),
201+
),
202+
Effect.flip,
203+
);
204+
expect(String(error)).toContain("A T3 Code server is already running");
205+
expect(String(error)).toContain(`pid ${child.pid}`);
206+
expect(yield* fs.exists(cwd)).toBe(false);
207+
}),
208+
);
209+
145210
it.effect("enables a trimmed reusable auth token only for web dev mode", () =>
146211
Effect.gen(function* () {
147212
const baseDir = yield* FileSystem.FileSystem.pipe(

‎apps/server/src/cli/config.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,13 @@ export const resolveServerConfig = (
333333
// and supervisor handoff are separate; this preflight cannot arbitrate two starts.
334334
if (options?.rejectRunningServer && mode === "web") {
335335
const runtime = yield* readPersistedServerRuntimeState(derivedPaths.serverRuntimeStatePath);
336-
if (Option.isSome(runtime) && runtime.value.pid > 0 && isProcessAlive(runtime.value.pid)) {
336+
// A killed container can leave a record with the new server's own PID.
337+
if (
338+
Option.isSome(runtime) &&
339+
runtime.value.pid > 0 &&
340+
runtime.value.pid !== process.pid &&
341+
isProcessAlive(runtime.value.pid)
342+
) {
337343
return yield* new CliError.UserError({
338344
cause: `A T3 Code server is already running for ${baseDir} (pid ${runtime.value.pid}, ${runtime.value.origin}). Connect to that server, stop it before starting another, or use a different --base-dir.`,
339345
});

0 commit comments

Comments
 (0)