authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-06 04:54:08-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-08-06 08:09:51-04:00
log8b2ea5721a213c3e3e3bcab56eaea6fa0fa82714
tree22e83038ba250d18c6cd26b5683de7a21e84db38
parentdf2f1273e9e1f1c6f342eecbf3539a5e992163e1

fix(server): order explicit service shutdown


4 files changed, 26 insertions(+), 16 deletions(-)

apps/server/src/cloud/bootService.test.ts+1-1
......@@ -31,7 +31,7 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server",
3131 });
3232
3333 expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs");
34 expect(unit).toContain("KillMode=control-group");
34 expect(unit).toContain("KillMode=mixed");
3535 expect(unit).not.toContain("versions/1.2.3");
3636});
3737
apps/server/src/cloud/bootService.ts+3-1
......@@ -64,7 +64,9 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
6464 `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
6565 `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
6666 `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`,
67 "KillMode=control-group",
67 // Let the launcher mark an explicit stop before it signals the server.
68 // systemd still SIGKILLs the whole cgroup if graceful shutdown times out.
69 "KillMode=mixed",
6870 "Restart=always",
6971 "RestartSec=5",
7072 `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`,
apps/server/src/serviceLauncher.test.ts+5-3
......@@ -112,11 +112,13 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
112112
113113 const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));
114114 const running = launcher.run();
115 yield* Effect.promise(() => launcher.stop("SIGTERM"));
116 yield* Effect.promise(() => running);
115 const stopping = launcher.stop("SIGTERM");
117116 // An explicit stop leaves the marker that tells a child shutting down
118 // mid-update that no replacement server is coming.
117 // mid-update that no replacement server is coming. It is present as
118 // soon as stop() returns its promise, before queued transitions run.
119119 assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE)));
120 yield* Effect.promise(() => stopping);
121 yield* Effect.promise(() => running);
120122 }),
121123 );
122124
apps/server/src/serviceLauncher.ts+17-11
......@@ -5,6 +5,7 @@
55// `t3 service update`. Keep runtime imports limited to Node built-ins.
66import * as NodeChildProcess from "node:child_process";
77import * as NodeCrypto from "node:crypto";
8import * as NodeFS from "node:fs";
89import * as NodeFSP from "node:fs/promises";
910import * as NodePath from "node:path";
1011
......@@ -268,6 +269,7 @@ export class Launcher {
268269 #child: ManagedChild | null = null;
269270 #timer: NodeJS.Timeout | undefined;
270271 #transitions: Promise<void> = Promise.resolve();
272 #stopRequested = false;
271273 #stopping = false;
272274 #done = false;
273275 readonly #completion = Promise.withResolvers<void>();
......@@ -312,22 +314,26 @@ export class Launcher {
312314 }
313315
314316 async stop(signal: NodeJS.Signals): Promise<void> {
315 if (this.#stopping) {
317 // This must happen synchronously at signal receipt. A queued update
318 // transition may already be terminating the active child, and that child
319 // needs to see the marker in its shutdown finalizer. KillMode=mixed also
320 // ensures systemd signals the launcher before the rest of the cgroup.
321 try {
322 NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 });
323 } catch {
324 // Err toward keeping the tunnel; the next link or unlink reconciles it.
325 }
326 if (this.#stopRequested || this.#stopping) {
316327 await this.#completion.promise.catch(() => undefined);
317328 return;
318329 }
319 this.#stopping = true;
330 this.#stopRequested = true;
320331 this.#clearTimer();
321332 this.#enqueue(async () => {
322 // An explicit stop is the service going away, not an update handoff.
323 // The marker lets the child's shutdown release the managed tunnel even
324 // while an update is recorded as pending; the next launcher start
325 // clears it. Written before the child is signalled so the child sees
326 // it during teardown. Best-effort: if the write fails the child errs
327 // toward keeping the tunnel, and the next link or unlink reconciles it.
328 await NodeFSP.writeFile(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }).catch(
329 () => undefined,
330 );
333 // Let an update transition already in progress start its replacement
334 // before this queued stop tears it down. That replacement owns the
335 // pre-activation tunnel cleanup path and observes the marker above.
336 this.#stopping = true;
331337 const child = this.#child?.process;
332338 this.#child = null;
333339 if (child !== undefined) await terminateChild(child, signal);