Skip to content

Commit d4e8a15

Browse files
author
env1-agent
committed
feat(service): join whole-service stop to a child drain over launcher IPC
A Windows SCM stop cannot deliver a graceful signal, so the launcher now asks its managed child to drain over the existing IPC channel and waits, bounded, for a stopped acknowledgement before its forced fallback. The child drives the same Effect-runtime interruption path as SIGTERM via process.emit, and acknowledges from a scope finalizer. POSIX keeps its signal-driven finalizer path. Author scope: ENV-1:R10-T3-LIFECYCLE Stage B.
1 parent 305360a commit d4e8a15

5 files changed

Lines changed: 166 additions & 4 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,37 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o
251251
});
252252

253253
export const layer = Layer.effect(ServiceLauncherClient, make());
254+
255+
/**
256+
* Installs the managed graceful-stop handler. When the launcher owns this
257+
* process it asks for a stop over the existing child IPC channel; this drives
258+
* the same Effect-runtime interruption path the runtime already installs for
259+
* `SIGTERM`, then acknowledges the drain from a scope finalizer so the launcher
260+
* can distinguish a completed shutdown from a forced one. A server that is not
261+
* launcher-managed (or that receives no stop request) is unaffected.
262+
*
263+
* `process.emit("SIGTERM")` is used rather than `process.kill` so the same
264+
* finalizer path runs on Windows, where a process signal is a hard kill.
265+
*/
266+
export const managedShutdownLayer = Layer.effectDiscard(
267+
Effect.gen(function* () {
268+
const { host, managed } = yield* resolveStartup();
269+
if (!managed) return;
270+
const onMessage = (...args: ReadonlyArray<unknown>) => {
271+
const message = decodeServiceLauncherParentMessage(args[0]);
272+
if (message?.type === "stop") process.emit("SIGTERM");
273+
};
274+
host.on("message", onMessage);
275+
yield* Effect.addFinalizer(() =>
276+
Effect.sync(() => {
277+
host.off("message", onMessage);
278+
try {
279+
host.send({ type: "stopped" });
280+
} catch {
281+
// The channel may already be closed; the launcher then falls back to
282+
// its bounded force-termination instead of trusting this ack.
283+
}
284+
}),
285+
);
286+
}),
287+
);

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export type ServiceLauncherChildMessage =
4747
| {
4848
readonly type: "prepared";
4949
readonly updateId: string;
50+
}
51+
/** Sent once the managed child has drained its lifetime and is about to exit
52+
in response to {@link ServiceLauncherParentMessage} `stop`. It is the
53+
acknowledgement that a graceful stop completed, not a request to stop. */
54+
| {
55+
readonly type: "stopped";
5056
};
5157

5258
export type ServiceLauncherParentMessage =
@@ -61,6 +67,12 @@ export type ServiceLauncherParentMessage =
6167
| {
6268
readonly type: "committed";
6369
readonly updateId: string;
70+
}
71+
/** Asks the managed child to run its own graceful shutdown and finalizers.
72+
Used on platforms where a process signal is a hard kill (Windows), so the
73+
launcher can join a real drain instead of terminating the child. */
74+
| {
75+
readonly type: "stop";
6476
};
6577

6678
const SEMVER_NUMBER = "(?:0|[1-9]\\d*)";
@@ -249,7 +261,9 @@ export function decodeServiceLauncherChildMessage(
249261
}
250262
return value.type === "prepared" && typeof value.updateId === "string"
251263
? { type: value.type, updateId: value.updateId }
252-
: undefined;
264+
: value.type === "stopped"
265+
? { type: value.type }
266+
: undefined;
253267
}
254268

255269
export function decodeServiceLauncherParentMessage(
@@ -264,5 +278,7 @@ export function decodeServiceLauncherParentMessage(
264278
}
265279
return value.type === "committed" && typeof value.updateId === "string"
266280
? { type: value.type, updateId: value.updateId }
267-
: undefined;
281+
: value.type === "stop"
282+
? { type: value.type }
283+
: undefined;
268284
}

‎apps/server/src/server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,8 @@ const makeServerLayer = Layer.unwrap(
797797
runtimeStateLayer.pipe(Layer.provide(launcherLayer)),
798798
tailscaleServeLayer,
799799
cloudDesiredLinkReconcileLayer,
800+
// Additive: lets a launcher-owned process drain on an IPC stop request.
801+
ServiceLauncherClient.managedShutdownLayer,
800802
);
801803

802804
return serverApplicationLayer.pipe(

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
// @effect-diagnostics nodeBuiltinImport:off
12
import * as NodeServices from "@effect/platform-node/NodeServices";
23
import { assert, it } from "@effect/vitest";
34
import * as Effect from "effect/Effect";
45
import * as FileSystem from "effect/FileSystem";
56
import * as Path from "effect/Path";
7+
import * as NodeChildProcess from "node:child_process";
68

7-
import { Launcher, readServiceState, writeServiceState } from "./serviceLauncher.ts";
9+
import {
10+
Launcher,
11+
readServiceState,
12+
requestGracefulChildStop,
13+
writeServiceState,
14+
} from "./serviceLauncher.ts";
815
import {
916
compareExactServiceVersions,
1017
decodeServiceState,
@@ -360,3 +367,57 @@ if (context.update?.status === "pending") {
360367
}),
361368
);
362369
});
370+
371+
it.layer(NodeServices.layer)("whole-service graceful stop over IPC", (it) => {
372+
it.effect("drains the managed child over IPC before force-termination", () =>
373+
Effect.gen(function* () {
374+
// POSIX keeps its signal-driven finalizer path; the IPC drain is the
375+
// Windows path where a signal would be a hard kill. The production helper
376+
// is exercised against a real Node child at the real IPC boundary.
377+
const fs = yield* FileSystem.FileSystem;
378+
const path = yield* Path.Path;
379+
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-graceful-" });
380+
const drainMarker = path.join(root, "drained.txt");
381+
// @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in the child source.
382+
const encodedDrainMarker = JSON.stringify(drainMarker);
383+
const childSource =
384+
`const { writeFileSync } = require("node:fs");\n` +
385+
`process.on("message", (message) => {\n` +
386+
` if (message && message.type === "stop") {\n` +
387+
` setTimeout(() => {\n` +
388+
` writeFileSync(${encodedDrainMarker}, "drained");\n` +
389+
` process.send({ type: "stopped" });\n` +
390+
` process.exit(0);\n` +
391+
` }, 25);\n` +
392+
` }\n` +
393+
`});\n` +
394+
`setInterval(() => {}, 1_000);\n`;
395+
const child = NodeChildProcess.spawn(process.execPath, ["-e", childSource], {
396+
stdio: ["ignore", "ignore", "inherit", "ipc"],
397+
});
398+
yield* Effect.promise(() => new Promise<void>((r) => child.once("spawn", () => r())));
399+
yield* Effect.promise(() => requestGracefulChildStop(child));
400+
yield* Effect.promise(() => new Promise<void>((r) => child.once("exit", () => r())));
401+
402+
// The child ran its own drain before the helper resolved.
403+
assert.isTrue(yield* fs.exists(drainMarker));
404+
}),
405+
);
406+
407+
it.effect("does not wait for a child without a live IPC channel", () =>
408+
Effect.gen(function* () {
409+
const child = NodeChildProcess.spawn(
410+
process.execPath,
411+
["-e", "setInterval(() => {}, 1_000);"],
412+
{
413+
stdio: ["ignore", "ignore", "inherit"],
414+
},
415+
);
416+
yield* Effect.promise(() => new Promise<void>((r) => child.once("spawn", () => r())));
417+
// No `ipc` stdio: the helper must return without driving a drain and the
418+
// caller falls back to its bounded force-termination.
419+
yield* Effect.promise(() => requestGracefulChildStop(child));
420+
child.kill();
421+
}),
422+
);
423+
});

‎apps/server/src/serviceLauncher.ts‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import {
3333
const HANDOFF_DELAY_MS = 2_000;
3434
const PREPARED_TIMEOUT_MS = 120_000;
3535
const TERMINATE_GRACE_MS = 5_000;
36+
/** Bound on how long a Windows whole-service stop waits for the managed child
37+
to drain and acknowledge before it force-terminates the process. */
38+
const STOP_ACK_TIMEOUT_MS = 10_000;
3639

3740
type TerminalStatus = "committed" | "rolled-back" | "failed";
3841
type ChildRole = "active" | "trial";
@@ -258,6 +261,40 @@ function waitForExit(child: NodeChildProcess.ChildProcess): Promise<void> {
258261
return new Promise((resolve) => child.once("exit", () => resolve()));
259262
}
260263

264+
/**
265+
* Asks the managed child to run its own graceful shutdown over the existing
266+
* IPC channel and waits, bounded, for the `stopped` acknowledgement or the
267+
* child's own exit. Returns without forcing; the caller still terminates to
268+
* guarantee the process is gone. Only meaningful where a signal is a hard kill
269+
* (Windows), so POSIX keeps its existing signal-driven drain.
270+
*/
271+
export function requestGracefulChildStop(child: NodeChildProcess.ChildProcess): Promise<void> {
272+
if (!child.connected || child.send === undefined) return Promise.resolve();
273+
return new Promise<void>((resolve) => {
274+
let settled = false;
275+
let timer: NodeJS.Timeout;
276+
function finish() {
277+
if (settled) return;
278+
settled = true;
279+
clearTimeout(timer);
280+
child.off("message", onMessage);
281+
child.off("exit", onExit);
282+
resolve();
283+
}
284+
function onMessage(value: unknown) {
285+
const reply = decodeServiceLauncherChildMessage(value);
286+
if (reply?.type === "stopped") finish();
287+
}
288+
function onExit() {
289+
finish();
290+
}
291+
timer = setTimeout(finish, STOP_ACK_TIMEOUT_MS);
292+
child.on("message", onMessage);
293+
child.on("exit", onExit);
294+
sendMessage(child, { type: "stop" }).catch(() => finish());
295+
});
296+
}
297+
261298
async function terminateChild(
262299
child: NodeChildProcess.ChildProcess,
263300
signal: NodeJS.Signals = "SIGTERM",
@@ -353,7 +390,14 @@ export class Launcher {
353390
this.#stopping = true;
354391
const child = this.#child?.process;
355392
this.#child = null;
356-
if (child !== undefined) await terminateChild(child, signal);
393+
if (child !== undefined) {
394+
// Windows cannot deliver a graceful signal: request the child's own
395+
// lifetime drain over IPC first, then guarantee the process is gone.
396+
// POSIX keeps its existing signal-driven finalizer path.
397+
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime.
398+
if (process.platform === "win32") await requestGracefulChildStop(child);
399+
await terminateChild(child, signal);
400+
}
357401
this.#done = true;
358402
this.#completion.resolve();
359403
});
@@ -472,6 +516,11 @@ export class Launcher {
472516
await this.#handleUpdateRequest(child, message);
473517
return;
474518
}
519+
if (message.type === "stopped") {
520+
// Owned by the dedicated whole-service stop listener in
521+
// requestGracefulChildStop; a replacement handoff ignores it.
522+
return;
523+
}
475524
await this.#handlePrepared(child, message.updateId);
476525
}
477526

0 commit comments

Comments
 (0)