Skip to content

Commit 449ae8e

Browse files
committed
fix(server): finish stale process identity checks
1 parent 6869ecc commit 449ae8e

12 files changed

Lines changed: 158 additions & 43 deletions

File tree

‎apps/server/src/bin.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as NodePath from "node:path";
66

77
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
88
import * as NodeServices from "@effect/platform-node/NodeServices";
9+
import * as ProcessRunner from "./processRunner.ts";
910
import {
1011
CommandId,
1112
EnvironmentOrchestrationHttpApi,
@@ -184,7 +185,9 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
184185
);
185186
});
186187

187-
it.layer(NodeServices.layer)("bin cli parsing", (it) => {
188+
const TestPlatformLayer = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer));
189+
190+
it.layer(TestPlatformLayer)("bin cli parsing", (it) => {
188191
it.effect("accepts the built-in lowercase log-level flag values", () =>
189192
Effect.gen(function* () {
190193
const { output } = yield* captureStdout(runCli(["--log-level", "debug", "--version"]));

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
4949
import * as ExternalLauncher from "../process/externalLauncher.ts";
5050
import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
5151
import { requireServerStopped } from "../serverOwnership.ts";
52+
import * as ProcessRunner from "../processRunner.ts";
5253
import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
5354
import { resolveCliCommand } from "./invocation.ts";
5455
import {
@@ -435,6 +436,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
435436
| FileSystem.FileSystem
436437
| HttpClient.HttpClient
437438
| Prompt.Environment
439+
| ProcessRunner.ProcessRunner
438440
| ServerConfig.ServerConfig
439441
| ServerEnvironment.ServerEnvironmentIdentity
440442
>,
@@ -454,6 +456,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
454456
RelayClient.layerCloudflared({ baseDir: config.baseDir }),
455457
EnvironmentAuth.runtimeLayer,
456458
bootServiceLayer(config),
459+
ProcessRunner.layer,
457460
headlessRelayClientTracingLayer,
458461
).pipe(
459462
Layer.provideMerge(FetchHttpClient.layer),

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as NodeOS from "node:os";
55
import * as NodePath from "node:path";
66

77
import * as NodeServices from "@effect/platform-node/NodeServices";
8+
import * as ProcessRunner from "../processRunner.ts";
89
import * as NetService from "@t3tools/shared/Net";
910
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
1011
import { assert, describe, expect, it } from "@effect/vitest";
@@ -32,6 +33,8 @@ import {
3233

3334
import packageJson from "../../package.json" with { type: "json" };
3435

36+
const TestPlatformLayer = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer));
37+
3538
const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
3639

3740
const baseState = {
@@ -179,7 +182,7 @@ describe("t3 pair", () => {
179182
assert.equal(credentials[0]?.label, "t3 pair");
180183
}),
181184
).pipe(
182-
Effect.provide(NodeServices.layer),
185+
Effect.provide(TestPlatformLayer),
183186
Effect.provideService(HostProcessEnvironment, {
184187
...process.env,
185188
[SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({
@@ -214,7 +217,7 @@ describe("t3 pair", () => {
214217

215218
assert.include(output, "Pairing URL: http://localhost:5733/pair#token=");
216219
}),
217-
).pipe(Effect.provide(NodeServices.layer)),
220+
).pipe(Effect.provide(TestPlatformLayer)),
218221
);
219222

220223
it.effect("directs to t3 serve or t3 connect when no server is running", () =>
@@ -231,7 +234,7 @@ describe("t3 pair", () => {
231234
assert.include(rendered, "No running T3 Code server found.");
232235
assert.include(rendered, "npx t3 serve");
233236
assert.include(rendered, "npx t3 connect");
234-
}).pipe(Effect.provide(NodeServices.layer)),
237+
}).pipe(Effect.provide(TestPlatformLayer)),
235238
);
236239

237240
it.effect("ignores runtime state whose recorded pid is no longer alive", () =>
@@ -261,7 +264,7 @@ describe("t3 pair", () => {
261264
);
262265
assert.include(rendered, "No running T3 Code server found.");
263266
}),
264-
).pipe(Effect.provide(NodeServices.layer)),
267+
).pipe(Effect.provide(TestPlatformLayer)),
265268
);
266269

267270
it.effect("ignores stale runtime state pointing at a dead server", () =>
@@ -286,6 +289,6 @@ describe("t3 pair", () => {
286289
typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
287290
);
288291
assert.include(rendered, "No running T3 Code server found.");
289-
}).pipe(Effect.provide(NodeServices.layer)),
292+
}).pipe(Effect.provide(TestPlatformLayer)),
290293
);
291294
});

‎apps/server/src/cloud/bootService.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
serviceStateHasPendingUpdate,
2525
} from "./serviceProtocol.ts";
2626

27+
const TestPlatformLayer = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer));
28+
2729
it("keeps systemd pinned to the stable launcher rather than a versioned server", () => {
2830
const unit = BootService.renderBootServiceUnit({
2931
nodePath: "/usr/bin/node",
@@ -211,7 +213,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
211213
return { service, makeService, fs, baseDir, statePath, commands, timeouts, control, runtime };
212214
});
213215

214-
it.layer(NodeServices.layer)("boot service install", (it) => {
216+
it.layer(TestPlatformLayer)("boot service install", (it) => {
215217
it.effect("requires an explicit SSH shutdown before installing the service", () =>
216218
Effect.gen(function* () {
217219
const { service, fs, baseDir, commands } = yield* makeHarness();
@@ -353,7 +355,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
353355

354356
it.effect.each([
355357
{ command: "systemctl --user show-environment", problem: "user-manager-unavailable" },
356-
{ command: "loginctl show-user 501 --property=Linger --value", problem: "linger-unavailable" },
358+
{
359+
command: "loginctl show-user 501 --property=Linger --value",
360+
problem: "linger-unavailable",
361+
},
357362
])("reports failed prerequisite probes without installing: $command", ({ command, problem }) =>
358363
Effect.gen(function* () {
359364
const { service, fs, statePath, control } = yield* makeHarness();

‎apps/server/src/cloud/bootService.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
703703
if (!installed)
704704
yield* requireServerStopped(runtimeStatePath).pipe(
705705
Effect.provideService(FileSystem.FileSystem, fs),
706+
Effect.provideService(ProcessRunner.ProcessRunner, runner),
706707
);
707708

708709
// A permissions failure must not leave a partial install or stop a working server.
@@ -773,6 +774,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
773774
// honor the lock, so restarting it could duplicate the unmanaged owner.
774775
yield* requireServerStopped(runtimeStatePath).pipe(
775776
Effect.provideService(FileSystem.FileSystem, fs),
777+
Effect.provideService(ProcessRunner.ProcessRunner, runner),
776778
);
777779

778780
yield* Effect.gen(function* () {

‎apps/server/src/server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ export const makeServerLayer = Layer.unwrap(
745745
Layer.provideMerge(PlatformServicesLive),
746746
);
747747
}),
748-
);
748+
).pipe(Layer.provide(ProcessRunner.layer));
749749

750750
// The CLI supplies configuration.
751751
export const runServer = Layer.launch(makeServerLayer);

‎apps/server/src/serverOwnership.ts‎

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
// @effect-diagnostics nodeBuiltinImport:off - Publication must finish synchronously while the scope holds ownership.
22
import * as NodeCrypto from "node:crypto";
3-
import * as NodeChildProcess from "node:child_process";
43
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
54
import * as NodeFS from "node:fs";
65
import * as NodePath from "node:path";
76
import * as Effect from "effect/Effect";
7+
import * as Duration from "effect/Duration";
88
import * as Option from "effect/Option";
99
import * as Schema from "effect/Schema";
1010

11+
import * as ProcessRunner from "./processRunner.ts";
1112
import { acquireServerOwnershipLock } from "./serverOwnershipLock.ts";
1213

1314
import {
@@ -54,30 +55,29 @@ const legacyOwnerIsLive = Effect.fn("legacyOwnerIsLive")(function* (
5455
if (!Number.isFinite(recordedAt)) return true;
5556
const platform = yield* HostProcessPlatform;
5657
const windows = platform === "win32";
57-
const startedAt = yield* Effect.promise(
58-
() =>
59-
new Promise<number | undefined>((resolve) => {
60-
NodeChildProcess.execFile(
61-
windows ? "powershell.exe" : "ps",
62-
windows
63-
? [
64-
"-NoProfile",
65-
"-NonInteractive",
66-
"-Command",
67-
`(Get-Process -Id ${state.pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`,
68-
]
69-
: ["-p", String(state.pid), "-o", "lstart="],
70-
{ env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 2_000, maxBuffer: 16_384 },
71-
(error, stdout) => {
72-
const time = Date.parse(windows ? stdout.trim() : `${stdout.trim()} UTC`);
73-
resolve(error || !Number.isFinite(time) ? undefined : time);
74-
},
75-
);
76-
}),
77-
);
58+
const runner = yield* ProcessRunner.ProcessRunner;
59+
const result = yield* runner
60+
.run({
61+
command: windows ? "powershell.exe" : "ps",
62+
args: windows
63+
? [
64+
"-NoProfile",
65+
"-NonInteractive",
66+
"-Command",
67+
`(Get-Process -Id ${state.pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`,
68+
]
69+
: ["-p", String(state.pid), "-o", "lstart="],
70+
env: { LC_ALL: "C", TZ: "UTC" },
71+
timeout: Duration.seconds(2),
72+
maxOutputBytes: 16_384,
73+
})
74+
.pipe(Effect.option);
75+
if (Option.isNone(result) || result.value.code !== 0) return true;
76+
const output = result.value.stdout.trim();
77+
const startedAt = Date.parse(windows ? output : `${output} UTC`);
7878
// ps reports whole seconds. Unknown identity stays conservative, and no
7979
// process is ever signalled based on this comparison.
80-
return startedAt === undefined || startedAt <= recordedAt + 1_000;
80+
return !Number.isFinite(startedAt) || startedAt <= recordedAt + 1_000;
8181
});
8282

8383
/**

‎apps/server/src/serverRuntimeState.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as NodeEvents from "node:events";
77
import * as NodeURL from "node:url";
88
import { assert, describe, it } from "@effect/vitest";
99
import * as NodeServices from "@effect/platform-node/NodeServices";
10+
import * as ProcessRunner from "./processRunner.ts";
1011
import * as Effect from "effect/Effect";
1112
import * as FileSystem from "effect/FileSystem";
1213
import * as Layer from "effect/Layer";
@@ -19,6 +20,8 @@ import * as Schema from "effect/Schema";
1920
import * as ServerRuntimeState from "./serverRuntimeState.ts";
2021
import { acquireServerOwnership, persistServerRuntimeState } from "./serverOwnership.ts";
2122

23+
const TestPlatformLayer = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer));
24+
2225
const encodeRuntimeState = Schema.encodeSync(
2326
Schema.fromJsonString(ServerRuntimeState.PersistedServerRuntimeState),
2427
);
@@ -55,7 +58,7 @@ describe("serverRuntimeState", () => {
5558
const { ownerId, ...restoredState } = Option.getOrThrow(restored);
5659
assert.isString(ownerId);
5760
assert.deepEqual(restoredState, state);
58-
}).pipe(Effect.provide(NodeServices.layer)),
61+
}).pipe(Effect.provide(TestPlatformLayer)),
5962
);
6063

6164
it.effect("records the dev web URL when the server fronts a dev server", () =>
@@ -89,7 +92,7 @@ describe("serverRuntimeState", () => {
8992
);
9093

9194
assert.isTrue(Option.isNone(restored));
92-
}).pipe(Effect.provide(NodeServices.layer)),
95+
}).pipe(Effect.provide(TestPlatformLayer)),
9396
);
9497

9598
it.effect("preserves malformed state decode failures", () => {
@@ -193,7 +196,7 @@ describe("serverRuntimeState", () => {
193196
assert.equal(error.statePath, statePath);
194197
assert.instanceOf(error.cause, Error);
195198
}
196-
}).pipe(Effect.provide(NodeServices.layer)),
199+
}).pipe(Effect.provide(TestPlatformLayer)),
197200
);
198201
});
199202

@@ -203,6 +206,9 @@ const ownerProcessSource = `
203206
import * as Effect from "effect/Effect";
204207
import * as NodeServices from "@effect/platform-node/NodeServices";
205208
import { acquireServerOwnership } from "./src/serverOwnership.ts";
209+
import * as ProcessRunner from "./src/processRunner.ts";
210+
import * as Layer from "effect/Layer";
211+
const TestPlatformLayer = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer));
206212
let stop;
207213
const stopped = new Promise(resolve => { stop = resolve; });
208214
process.on("message", message => {
@@ -214,7 +220,7 @@ process.on("message", message => {
214220
origin: "http://127.0.0.1:45731", startedAt: "2026-09-04T00:00:00.000Z" });
215221
process.send("acquired");
216222
yield* Effect.promise(() => stopped);
217-
})).pipe(Effect.provide(NodeServices.layer))).then(
223+
})).pipe(Effect.provide(TestPlatformLayer))).then(
218224
() => process.exit(0),
219225
error => { process.send(error._tag ?? error.message); process.exit(1); },
220226
);
@@ -358,7 +364,7 @@ it.live("refuses a live legacy owner and replaces stale ownership despite PID re
358364
}),
359365
);
360366
yield* Effect.scoped(acquireServerOwnership(statePath));
361-
}).pipe(Effect.provide(NodeServices.layer)),
367+
}).pipe(Effect.provide(TestPlatformLayer)),
362368
);
363369

364370
it.effect("rejects publication after ownership has been released", () =>
@@ -382,5 +388,5 @@ it.effect("rejects publication after ownership has been released", () =>
382388
const failure = yield* stalePublish.pipe(Effect.flip);
383389
assert.equal(failure._tag, "ServerOwnershipReleasedError");
384390
assert.equal(yield* fs.readFileString(statePath), before);
385-
}).pipe(Effect.provide(NodeServices.layer)),
391+
}).pipe(Effect.provide(TestPlatformLayer)),
386392
);

‎apps/server/src/serviceLauncher.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import * as ProcessRunner from "./processRunner.ts";
23
import { assert, it } from "@effect/vitest";
34
import * as Effect from "effect/Effect";
5+
import * as Layer from "effect/Layer";
46
import * as FileSystem from "effect/FileSystem";
57
import * as Path from "effect/Path";
68

@@ -14,6 +16,8 @@ import {
1416
SERVICE_STOP_MARKER_FILE,
1517
} from "./cloud/serviceProtocol.ts";
1618

19+
const TestPlatformLayer = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer));
20+
1721
it("accepts only exact semantic versions", () => {
1822
for (const version of ["0.0.0", "1.2.3", "1.2.3-alpha.1", "1.2.3-0", "1.2.3+001"]) {
1923
assert.isTrue(isExactServiceVersion(version), version);
@@ -76,7 +80,7 @@ it("rejects contradictory service state", () => {
7680
);
7781
});
7882

79-
it.layer(NodeServices.layer)("service state persistence", (it) => {
83+
it.layer(TestPlatformLayer)("service state persistence", (it) => {
8084
it.effect("durably replaces and strictly reads one state document", () =>
8185
Effect.gen(function* () {
8286
const fs = yield* FileSystem.FileSystem;

‎docs/internals/remote.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ Servers discovered through the current runtime record, and old cache entries
5353
without a start time, must survive a client disconnect. Reconnection prefers the
5454
current runtime record over the saved SSH port, then restores the forward before
5555
opening the application transport. This lets SSH follow a service takeover
56-
without stopping the service or launching another server.
56+
without stopping the service or launching another server. If a stale PID causes a
57+
new launch attempt, server startup still enforces state-directory ownership.
5758

5859
Remote servers can outlive several client releases. Clients must use advertised
5960
capabilities and handle their absence, rather than assume their own version

0 commit comments

Comments
 (0)