diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index a9ae3f49f70b94f89ec377c35fbef9f53fcc218d..12eebbbd223fa6ea7905f959376387c2215f0c2b 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -31,7 +31,7 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", }); expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs"); - expect(unit).toContain("KillMode=control-group"); + expect(unit).toContain("KillMode=mixed"); expect(unit).not.toContain("versions/1.2.3"); }); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index ec110cb8b6a8e06fa5fa8b3ced95362c99d762ee..a57585be4b244377fa57991e68b615c209e1f973 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -64,7 +64,9 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`, - "KillMode=control-group", + // Let the launcher mark an explicit stop before it signals the server. + // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. + "KillMode=mixed", "Restart=always", "RestartSec=5", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 816bcacc73cabf7edf32e3968683afa5947b9462..45c472af1fc48628048d63447cc28a188feda3f6 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -112,11 +112,13 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); const running = launcher.run(); - yield* Effect.promise(() => launcher.stop("SIGTERM")); - yield* Effect.promise(() => running); + const stopping = launcher.stop("SIGTERM"); // An explicit stop leaves the marker that tells a child shutting down - // mid-update that no replacement server is coming. + // mid-update that no replacement server is coming. It is present as + // soon as stop() returns its promise, before queued transitions run. assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE))); + yield* Effect.promise(() => stopping); + yield* Effect.promise(() => running); }), ); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index b704dc4c0320bf7b28bcad6d89486ff4c9072b15..3641593ecf0794740c666e9ca0503a7c5839ece7 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -5,6 +5,7 @@ // `t3 service update`. Keep runtime imports limited to Node built-ins. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -268,6 +269,7 @@ export class Launcher { #child: ManagedChild | null = null; #timer: NodeJS.Timeout | undefined; #transitions: Promise = Promise.resolve(); + #stopRequested = false; #stopping = false; #done = false; readonly #completion = Promise.withResolvers(); @@ -312,22 +314,26 @@ export class Launcher { } async stop(signal: NodeJS.Signals): Promise { - if (this.#stopping) { + // This must happen synchronously at signal receipt. A queued update + // transition may already be terminating the active child, and that child + // needs to see the marker in its shutdown finalizer. KillMode=mixed also + // ensures systemd signals the launcher before the rest of the cgroup. + try { + NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }); + } catch { + // Err toward keeping the tunnel; the next link or unlink reconciles it. + } + if (this.#stopRequested || this.#stopping) { await this.#completion.promise.catch(() => undefined); return; } - this.#stopping = true; + this.#stopRequested = true; this.#clearTimer(); this.#enqueue(async () => { - // An explicit stop is the service going away, not an update handoff. - // The marker lets the child's shutdown release the managed tunnel even - // while an update is recorded as pending; the next launcher start - // clears it. Written before the child is signalled so the child sees - // it during teardown. Best-effort: if the write fails the child errs - // toward keeping the tunnel, and the next link or unlink reconciles it. - await NodeFSP.writeFile(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }).catch( - () => undefined, - ); + // Let an update transition already in progress start its replacement + // before this queued stop tears it down. That replacement owns the + // pre-activation tunnel cleanup path and observes the marker above. + this.#stopping = true; const child = this.#child?.process; this.#child = null; if (child !== undefined) await terminateChild(child, signal);