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",...@@ -31,7 +31,7 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server",
31 });31 });
3232
33 expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs");33 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");
35 expect(unit).not.toContain("versions/1.2.3");35 expect(unit).not.toContain("versions/1.2.3");
36});36});
3737
apps/server/src/cloud/bootService.ts+3-1
...@@ -64,7 +64,9 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {...@@ -64,7 +64,9 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
64 `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,64 `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
65 `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,65 `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
66 `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`,66 `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",
68 "Restart=always",70 "Restart=always",
69 "RestartSec=5",71 "RestartSec=5",
70 `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`,72 `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) => {...@@ -112,11 +112,13 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
112112
113 const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));113 const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));
114 const running = launcher.run();114 const running = launcher.run();
115 yield* Effect.promise(() => launcher.stop("SIGTERM"));115 const stopping = launcher.stop("SIGTERM");
116 yield* Effect.promise(() => running);
117 // An explicit stop leaves the marker that tells a child shutting down116 // 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.
119 assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE)));119 assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE)));
120 yield* Effect.promise(() => stopping);
121 yield* Effect.promise(() => running);
120 }),122 }),
121 );123 );
122124
apps/server/src/serviceLauncher.ts+17-11
...@@ -5,6 +5,7 @@...@@ -5,6 +5,7 @@
5// `t3 service update`. Keep runtime imports limited to Node built-ins.5// `t3 service update`. Keep runtime imports limited to Node built-ins.
6import * as NodeChildProcess from "node:child_process";6import * as NodeChildProcess from "node:child_process";
7import * as NodeCrypto from "node:crypto";7import * as NodeCrypto from "node:crypto";
8import * as NodeFS from "node:fs";
8import * as NodeFSP from "node:fs/promises";9import * as NodeFSP from "node:fs/promises";
9import * as NodePath from "node:path";10import * as NodePath from "node:path";
1011
...@@ -268,6 +269,7 @@ export class Launcher {...@@ -268,6 +269,7 @@ export class Launcher {
268 #child: ManagedChild | null = null;269 #child: ManagedChild | null = null;
269 #timer: NodeJS.Timeout | undefined;270 #timer: NodeJS.Timeout | undefined;
270 #transitions: Promise<void> = Promise.resolve();271 #transitions: Promise<void> = Promise.resolve();
272 #stopRequested = false;
271 #stopping = false;273 #stopping = false;
272 #done = false;274 #done = false;
273 readonly #completion = Promise.withResolvers<void>();275 readonly #completion = Promise.withResolvers<void>();
...@@ -312,22 +314,26 @@ export class Launcher {...@@ -312,22 +314,26 @@ export class Launcher {
312 }314 }
313315
314 async stop(signal: NodeJS.Signals): Promise<void> {316 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) {
316 await this.#completion.promise.catch(() => undefined);327 await this.#completion.promise.catch(() => undefined);
317 return;328 return;
318 }329 }
319 this.#stopping = true;330 this.#stopRequested = true;
320 this.#clearTimer();331 this.#clearTimer();
321 this.#enqueue(async () => {332 this.#enqueue(async () => {
322 // An explicit stop is the service going away, not an update handoff.333 // Let an update transition already in progress start its replacement
323 // The marker lets the child's shutdown release the managed tunnel even334 // before this queued stop tears it down. That replacement owns the
324 // while an update is recorded as pending; the next launcher start335 // pre-activation tunnel cleanup path and observes the marker above.
325 // clears it. Written before the child is signalled so the child sees336 this.#stopping = true;
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 );
331 const child = this.#child?.process;337 const child = this.#child?.process;
332 this.#child = null;338 this.#child = null;
333 if (child !== undefined) await terminateChild(child, signal);339 if (child !== undefined) await terminateChild(child, signal);