Skip to content

Commit ecb8ddd

Browse files
authored
fix(server): desktop backend now exits when its launcher dies (#20277)
1 parent 2df5dbe commit ecb8ddd

3 files changed

Lines changed: 120 additions & 1 deletion

File tree

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Command, GlobalFlag } from "effect/cli";
77
import * as CliError from "effect/cli/CliError";
88

99
import * as ServerConfig from "../config.ts";
10+
import { awaitLauncherLoss } from "../launcherWatch.ts";
1011
import { runServer } from "../server.ts";
1112
import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts";
1213

@@ -23,7 +24,15 @@ const runServerCommand = (
2324
Effect.gen(function* () {
2425
const logLevel = yield* GlobalFlag.LogLevel;
2526
const config = yield* resolveServerConfig(flags, logLevel, options);
26-
return yield* runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config));
27+
const run = runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config));
28+
if (config.mode !== "desktop" || config.desktopBootstrapToken === undefined) return yield* run;
29+
return yield* Effect.raceFirst(
30+
run,
31+
awaitLauncherLoss().pipe(
32+
// End like SIGTERM does (interrupt-only exit, so the runtime forces process exit).
33+
Effect.andThen(Effect.interrupt),
34+
),
35+
);
2736
});
2837

2938
export const runDefaultServerCommand = (flags: CliServerFlags) =>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { assert, it } from "@effect/vitest";
2+
import * as Effect from "effect/Effect";
3+
import * as Fiber from "effect/Fiber";
4+
import * as TestClock from "effect/testing/TestClock";
5+
import * as HostProcess from "@t3tools/shared/HostProcess";
6+
7+
import { awaitLauncherLoss } from "./launcherWatch.ts";
8+
9+
const launcherPid = 4100;
10+
// Orphans are adopted by init or a subreaper, so the new parent is not always 1.
11+
const subreaperPid = 1800;
12+
13+
const watch = (getParentPid: () => number, platform: NodeJS.Platform, startPid = launcherPid) =>
14+
awaitLauncherLoss({ launcherPid: startPid, getParentPid }).pipe(
15+
Effect.provideService(HostProcess.Platform, platform),
16+
);
17+
18+
it.effect("completes when the parent changes, including to a subreaper", () =>
19+
Effect.gen(function* () {
20+
let parentPid = launcherPid;
21+
const fiber = yield* Effect.forkChild(watch(() => parentPid, "linux"));
22+
yield* Effect.yieldNow;
23+
24+
yield* TestClock.adjust("10 seconds");
25+
assert.isUndefined(fiber.pollUnsafe(), "still watching while the launcher is alive");
26+
27+
parentPid = subreaperPid;
28+
yield* TestClock.adjust("2 seconds");
29+
yield* Fiber.join(fiber);
30+
}),
31+
);
32+
33+
it.effect("notices a launcher that was already gone when the watch began", () =>
34+
Effect.gen(function* () {
35+
const fiber = yield* Effect.forkChild(watch(() => subreaperPid, "linux"));
36+
yield* Effect.yieldNow;
37+
yield* Fiber.join(fiber);
38+
}),
39+
);
40+
41+
it.effect("does not watch a backend that already starts under init", () =>
42+
Effect.gen(function* () {
43+
let parentPid = 1;
44+
const fiber = yield* Effect.forkChild(watch(() => parentPid, "linux", 1));
45+
yield* Effect.yieldNow;
46+
47+
parentPid = subreaperPid;
48+
yield* TestClock.adjust("1 minute");
49+
assert.isUndefined(fiber.pollUnsafe());
50+
yield* Fiber.interrupt(fiber);
51+
}),
52+
);
53+
54+
it.effect("does not watch on Windows, where the original parent id outlives the parent", () =>
55+
Effect.gen(function* () {
56+
let parentPid = launcherPid;
57+
const fiber = yield* Effect.forkChild(watch(() => parentPid, "win32"));
58+
yield* Effect.yieldNow;
59+
60+
parentPid = subreaperPid;
61+
yield* TestClock.adjust("1 minute");
62+
assert.isUndefined(fiber.pollUnsafe());
63+
yield* Fiber.interrupt(fiber);
64+
}),
65+
);

‎apps/server/src/launcherWatch.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as Duration from "effect/Duration";
2+
import * as Effect from "effect/Effect";
3+
import * as HostProcess from "@t3tools/shared/HostProcess";
4+
5+
const DEFAULT_POLL_INTERVAL = Duration.seconds(2);
6+
7+
// Sampled when this module loads, before config resolution and the bootstrap
8+
// read, so a launcher that dies during startup is still noticed.
9+
const startupParentPid = process.ppid;
10+
11+
/**
12+
* Completes when the process that launched this backend is gone.
13+
*
14+
* A desktop-launched backend reads its bootstrap envelope once and never looks
15+
* at its launcher again. When the launcher dies without a graceful stop (a WSL
16+
* relay killed during an app update, for instance), the backend would keep
17+
* serving and holding its ownership lock while no desktop can reach it, and
18+
* every replacement launch is refused. An orphan is adopted by init or, on
19+
* Linux, by a subreaper, so any change of parent counts rather than only a
20+
* move to pid 1. A backend that already starts under init has no launcher to
21+
* lose, and Windows keeps the original parent id after the parent exits, so
22+
* neither is watched.
23+
*/
24+
export const awaitLauncherLoss = (options?: {
25+
readonly launcherPid?: number;
26+
readonly getParentPid?: () => number;
27+
readonly interval?: Duration.Input;
28+
}): Effect.Effect<void> =>
29+
Effect.gen(function* () {
30+
const platform = yield* HostProcess.Platform;
31+
const launcherPid = options?.launcherPid ?? startupParentPid;
32+
const getParentPid = options?.getParentPid ?? (() => process.ppid);
33+
const interval = options?.interval ?? DEFAULT_POLL_INTERVAL;
34+
if (platform === "win32" || launcherPid <= 1) return yield* Effect.never;
35+
const poll: Effect.Effect<void> = Effect.suspend(() => {
36+
const parentPid = getParentPid();
37+
return parentPid === launcherPid
38+
? Effect.flatMap(Effect.sleep(interval), () => poll)
39+
: Effect.logWarning("desktop launcher is gone; shutting down the backend", {
40+
launcherPid,
41+
parentPid,
42+
});
43+
});
44+
return yield* poll;
45+
});

0 commit comments

Comments
 (0)