| 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,7 +31,7 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", |
| 31 | }); | 31 | }); |
| 32 | 32 | ||
| 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 | }); |
| 37 | 37 |
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) => { |
| 112 | 112 | ||
| 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 down | 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 | 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 | ); |
| 122 | 124 |
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. |
| 6 | import * as NodeChildProcess from "node:child_process"; | 6 | import * as NodeChildProcess from "node:child_process"; |
| 7 | import * as NodeCrypto from "node:crypto"; | 7 | import * as NodeCrypto from "node:crypto"; |
| 8 | import * as NodeFS from "node:fs"; | ||
| 8 | import * as NodeFSP from "node:fs/promises"; | 9 | import * as NodeFSP from "node:fs/promises"; |
| 9 | import * as NodePath from "node:path"; | 10 | import * as NodePath from "node:path"; |
| 10 | 11 | ||
| ... | @@ -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 | } |
| 313 | 315 | ||
| 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 even | 334 | // before this queued stop tears it down. That replacement owns the |
| 324 | // while an update is recorded as pending; the next launcher start | 335 | // pre-activation tunnel cleanup path and observes the marker above. |
| 325 | // clears it. Written before the child is signalled so the child sees | 336 | 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); |