| author | |
| committer | |
| log | 8b2ea5721a213c3e3e3bcab56eaea6fa0fa82714 |
| tree | 22e83038ba250d18c6cd26b5683de7a21e84db38 |
| parent | df2f1273e9e1f1c6f342eecbf3539a5e992163e1 |
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 | 31 | }); |
| 32 | 32 | |
| 33 | 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 | 35 | expect(unit).not.toContain("versions/1.2.3"); |
| 36 | 36 | }); |
| 37 | 37 |
apps/server/src/cloud/bootService.ts+3-1| ... | ... | @@ -64,7 +64,9 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { |
| 64 | 64 | `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, |
| 65 | 65 | `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, |
| 66 | 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 | 70 | "Restart=always", |
| 69 | 71 | "RestartSec=5", |
| 70 | 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 | 112 | |
| 113 | 113 | const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); |
| 114 | 114 | const running = launcher.run(); |
| 115 | yield* Effect.promise(() => launcher.stop("SIGTERM")); | |
| 116 | yield* Effect.promise(() => running); | |
| 115 | const stopping = launcher.stop("SIGTERM"); | |
| 117 | 116 | // 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 | 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 | ); |
| 122 | 124 |
apps/server/src/serviceLauncher.ts+17-11| ... | ... | @@ -5,6 +5,7 @@ |
| 5 | 5 | // `t3 service update`. Keep runtime imports limited to Node built-ins. |
| 6 | 6 | import * as NodeChildProcess from "node:child_process"; |
| 7 | 7 | import * as NodeCrypto from "node:crypto"; |
| 8 | import * as NodeFS from "node:fs"; | |
| 8 | 9 | import * as NodeFSP from "node:fs/promises"; |
| 9 | 10 | import * as NodePath from "node:path"; |
| 10 | 11 | |
| ... | ... | @@ -268,6 +269,7 @@ export class Launcher { |
| 268 | 269 | #child: ManagedChild | null = null; |
| 269 | 270 | #timer: NodeJS.Timeout | undefined; |
| 270 | 271 | #transitions: Promise<void> = Promise.resolve(); |
| 272 | #stopRequested = false; | |
| 271 | 273 | #stopping = false; |
| 272 | 274 | #done = false; |
| 273 | 275 | readonly #completion = Promise.withResolvers<void>(); |
| ... | ... | @@ -312,22 +314,26 @@ export class Launcher { |
| 312 | 314 | } |
| 313 | 315 | |
| 314 | 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 | 327 | await this.#completion.promise.catch(() => undefined); |
| 317 | 328 | return; |
| 318 | 329 | } |
| 319 | this.#stopping = true; | |
| 330 | this.#stopRequested = true; | |
| 320 | 331 | this.#clearTimer(); |
| 321 | 332 | 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; | |
| 331 | 337 | const child = this.#child?.process; |
| 332 | 338 | this.#child = null; |
| 333 | 339 | if (child !== undefined) await terminateChild(child, signal); |