authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-01 05:48:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-01 05:48:03-07:00
logd3037064e61a9f059eafbd4f9869679779bd2a7c
treedbabde7e2086de111b674d179e749d3a54fac5cb
parent0ad91b6e7fc1fcb6d5f4bc736d84c337e912bc62
signature Signed by PGP key B5690EEEBB952194

fix(server): make remote updates rollback-safe (#5181)


40 files changed, 2831 insertions(+), 2245 deletions(-)

apps/server/package.json+1-1
......@@ -16,7 +16,7 @@
1616 "type": "module",
1717 "scripts": {
1818 "dev": "node --watch src/bin.ts",
19 "build:bundle": "vp pack",
19 "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean",
2020 "start": "node dist/bin.mjs",
2121 "typecheck": "tsgo --noEmit",
2222 "test": "vp test run"
apps/server/scripts/cli.ts+5-1
......@@ -223,7 +223,11 @@ const publishCmd = Command.make(
223223 const packageJsonPath = path.join(serverDir, "package.json");
224224
225225 // Assert build assets exist
226 for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
226 for (const relPath of [
227 "dist/bin.mjs",
228 "dist/service-launcher.mjs",
229 "dist/client/index.html",
230 ]) {
227231 const abs = path.join(serverDir, relPath);
228232 if (!(yield* fs.exists(abs))) {
229233 return yield* new ServerCliBuildAssetMissingError({ assetPath: abs });
apps/server/src/bin.ts+2
......@@ -15,6 +15,7 @@ import { sharedServerCommandFlags } from "./cli/config.ts";
1515import { projectCommand } from "./cli/project.ts";
1616import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1717import { serviceCommand } from "./cli/service.ts";
18import { servicePreflightCommand } from "./cli/servicePreflight.ts";
1819
1920const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
2021
......@@ -53,6 +54,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
5354 authCommand,
5455 projectCommand,
5556 serviceCommand,
57 servicePreflightCommand,
5658 cloudEnabled ? connectCommand : connectUnavailableCommand,
5759 ]),
5860 );
apps/server/src/cli/servicePreflight.ts created+17
......@@ -0,0 +1,17 @@
1import * as Console from "effect/Console";
2import * as Effect from "effect/Effect";
3import { Command, Flag } from "effect/unstable/cli";
4
5import { runServicePreflight } from "../cloud/servicePreflight.ts";
6
7export const servicePreflightCommand = Command.make("__service-preflight", {
8 databasePath: Flag.string("database-path"),
9 launcherProtocol: Flag.integer("launcher-protocol"),
10}).pipe(
11 Command.withHidden,
12 Command.withHandler(({ databasePath, launcherProtocol }) =>
13 Console.log(JSON.stringify(runServicePreflight({ databasePath, launcherProtocol }))).pipe(
14 Effect.asVoid,
15 ),
16 ),
17);
apps/server/src/cloud/bootService.test.ts+92-514
......@@ -1,558 +1,136 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
2import { expect, it } from "@effect/vitest";
3import {
4 HostProcessArguments,
5 HostProcessExecutablePath,
6 HostProcessPlatform,
7} from "@t3tools/shared/hostProcess";
38import * as ConfigProvider from "effect/ConfigProvider";
49import * as Effect from "effect/Effect";
510import * as FileSystem from "effect/FileSystem";
611import * as Layer from "effect/Layer";
712import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
913import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1014
11import {
12 HostProcessArguments,
13 HostProcessExecutablePath,
14 HostProcessPlatform,
15} from "@t3tools/shared/hostProcess";
16
17import { reconcileService } from "../cli/service.ts";
1815import * as ProcessRunner from "../processRunner.ts";
1916import * as BootService from "./bootService.ts";
17import { pinnedRuntimePaths } from "./pinnedRuntime.ts";
18import { parseServiceState } from "./serviceProtocol.ts";
2019
21const isUnsupportedError = Schema.is(BootService.BootServiceUnsupportedError);
22const isCommandError = Schema.is(BootService.BootServiceCommandError);
23
24interface RecordedCommand {
25 readonly command: string;
26 readonly args: ReadonlyArray<string>;
27}
28
29const makeRecordingRunnerLayer = (
30 commands: Array<RecordedCommand>,
31 options?: {
32 readonly failCommand?: string;
33 readonly failWhen?: (command: string, args: ReadonlyArray<string>) => boolean;
34 },
35) =>
36 Layer.succeed(
37 ProcessRunner.ProcessRunner,
38 ProcessRunner.ProcessRunner.of({
39 run: (input) =>
40 Effect.sync(() => {
41 assert.isUndefined(input.env);
42 commands.push({ command: input.command, args: input.args });
43 const failed =
44 input.command === options?.failCommand ||
45 options?.failWhen?.(input.command, input.args) === true;
46 return {
47 stdout: "",
48 stderr: failed ? `${input.command} exploded` : "",
49 code: ChildProcessSpawner.ExitCode(failed ? 1 : 0),
50 timedOut: false,
51 stdoutTruncated: false,
52 stderrTruncated: false,
53 };
54 }),
55 }),
56 );
57
58const makeHost = (entry: string): BootService.BootServiceHost => ({
59 execPath: "/usr/local/bin/node",
60 cliEntryPath: entry,
61});
62
63const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") =>
64 Effect.provide(
65 Layer.mergeAll(
66 Layer.succeed(HostProcessPlatform, platform),
67 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
68 ),
69 );
70
71const makeTestContext = Effect.fn("test.makeTestContext")(function* () {
72 const fs = yield* FileSystem.FileSystem;
73 const path = yield* Path.Path;
74 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" });
75 // A real file for the stable-entry cases so status can confirm the entry
76 // point exists.
77 const stableEntry = path.join(root, "bin.mjs");
78 yield* fs.writeFileString(stableEntry, "#!/usr/bin/env node\n");
79 return {
80 fs,
81 path,
82 dirs: {
83 home: root,
84 baseDir: path.join(root, ".t3"),
85 logsDir: path.join(root, ".t3", "userdata", "logs"),
86 stableEntry,
87 },
88 };
89});
90
91it("renders a systemd unit with absolute paths and append-mode logging", () => {
20it("keeps systemd pinned to the stable launcher rather than a versioned server", () => {
9221 const unit = BootService.renderBootServiceUnit({
93 nodePath: "/usr/local/bin/node",
94 t3EntryPath: "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs",
22 nodePath: "/usr/bin/node",
23 launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs",
9524 baseDir: "/home/theo/.t3",
9625 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
9726 unitPath: "/home/theo/.config/systemd/user/t3code.service",
9827 });
9928
100 assert.equal(
101 unit,
102 [
103 "[Unit]",
104 "Description=T3 Code server",
105 "StartLimitIntervalSec=300",
106 "StartLimitBurst=5",
107 "",
108 "[Service]",
109 "Type=simple",
110 "WorkingDirectory=%h",
111 "Environment=T3CODE_HOME=/home/theo/.t3",
112 "Environment=T3_BOOT_SERVICE_UNIT=t3code.service",
113 "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve",
114 "Restart=always",
115 "RestartSec=5",
116 "StandardOutput=append:/home/theo/.t3/userdata/logs/boot-service.log",
117 "StandardError=append:/home/theo/.t3/userdata/logs/boot-service.log",
118 "",
119 "[Install]",
120 "WantedBy=default.target",
121 "",
122 ].join("\n"),
123 );
29 expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs");
30 expect(unit).toContain("KillMode=control-group");
31 expect(unit).not.toContain("versions/1.2.3");
12432});
12533
126it("quotes systemd values containing spaces and escapes percent specifiers", () => {
127 assert.equal(BootService.quoteSystemdValue("/plain/path"), "/plain/path");
128 assert.equal(BootService.quoteSystemdValue("/home/me/T3 Data"), '"/home/me/T3 Data"');
129 assert.equal(BootService.quoteSystemdValue("/opt/100%cpu"), "/opt/100%%cpu");
130
131 const unit = BootService.renderBootServiceUnit({
132 nodePath: "/home/me/my tools/node",
133 t3EntryPath: "/home/me/T3 Data/bin.mjs",
134 baseDir: "/home/me/T3 Data",
135 logPath: "/home/me/100%logs/boot.log",
136 unitPath: "/home/me/.config/systemd/user/t3code.service",
34const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
35 platform: NodeJS.Platform = "linux",
36) {
37 const fs = yield* FileSystem.FileSystem;
38 const path = yield* Path.Path;
39 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" });
40 const baseDir = path.join(home, ".t3");
41 const sourceLauncher = path.join(home, "service-launcher.mjs");
42 const statePath = path.join(baseDir, "runtime", "service-state.json");
43 yield* fs.writeFileString(sourceLauncher, "export {};\n");
44 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3");
45 yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });
46 yield* fs.writeFileString(runtime.entryPath, "export {};\n");
47 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");
48
49 const commands: string[] = [];
50 const control: { failCommand: string | undefined } = { failCommand: undefined };
51 const runner = ProcessRunner.ProcessRunner.of({
52 run: (input) =>
53 Effect.sync(() => {
54 const command = `${input.command} ${input.args.join(" ")}`;
55 commands.push(command);
56 return {
57 stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
58 stderr: "",
59 code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0),
60 timedOut: false,
61 stdoutTruncated: false,
62 stderrTruncated: false,
63 };
64 }),
13765 });
138 assert.include(unit, 'ExecStart="/home/me/my tools/node" "/home/me/T3 Data/bin.mjs" serve');
139 assert.include(unit, 'Environment=T3CODE_HOME="/home/me/T3 Data"');
140 // append: paths take the rest of the line literally (spaces are fine,
141 // quoting is not), but % still goes through specifier expansion.
142 assert.include(unit, "StandardOutput=append:/home/me/100%%logs/boot.log");
143 assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log");
144});
145
146it("flags package-manager cache entry points as ephemeral", () => {
147 assert.isTrue(
148 BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"),
149 );
150 assert.isTrue(
151 BootService.isEphemeralCacheEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"),
152 );
153 assert.isTrue(
154 BootService.isEphemeralCacheEntry(
155 "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs",
156 ),
157 );
158 assert.isTrue(
159 BootService.isEphemeralCacheEntry("/home/theo/.bun/install/cache/t3@0.0.27/dist/bin.mjs"),
160 );
161 assert.isFalse(BootService.isEphemeralCacheEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs"));
162 assert.isFalse(
163 BootService.isEphemeralCacheEntry(
164 "/home/theo/dev/pnpm/dlx-tools/t3/node_modules/t3/dist/bin.mjs",
165 ),
166 );
167 assert.isFalse(
168 BootService.isEphemeralCacheEntry(
169 "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs",
66 const service = yield* BootService.make({
67 baseDir,
68 logsDir: path.join(baseDir, "userdata", "logs"),
69 cliVersion: "1.2.3",
70 host: {
71 execPath: "/usr/bin/node",
72 cliEntryPath: path.join(home, "bin.mjs"),
73 launcherSourcePath: sourceLauncher,
74 },
75 }).pipe(
76 Effect.provideService(ProcessRunner.ProcessRunner, runner),
77 Effect.provide(
78 Layer.mergeAll(
79 Layer.succeed(HostProcessPlatform, platform),
80 Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"),
81 Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]),
82 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
83 ),
17084 ),
17185 );
86 return { service, fs, statePath, commands, control };
17287});
17388
174it.layer(NodeServices.layer)("BootService", (it) => {
175 it.effect("reconciles the standalone service once and is then idempotent", () =>
176 Effect.gen(function* () {
177 const { dirs } = yield* makeTestContext();
178 const commands: Array<RecordedCommand> = [];
179 const service = yield* BootService.make({
180 baseDir: dirs.baseDir,
181 logsDir: dirs.logsDir,
182 cliVersion: "0.0.27",
183 host: makeHost(dirs.stableEntry),
184 }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
185
186 const first = yield* reconcileService().pipe(
187 Effect.provideService(BootService.BootService, service),
188 );
189 assert.isTrue(first.changed);
190 if (!first.changed) return;
191 assert.isFalse(first.previouslyInstalled);
192
193 const commandCount = commands.length;
194 const second = yield* reconcileService().pipe(
195 Effect.provideService(BootService.BootService, service),
196 );
197 assert.isFalse(second.changed);
198 assert.lengthOf(commands, commandCount);
199 }),
200 );
201
202 it.effect("installs the unit, enables the service, and enables linger", () =>
203 Effect.gen(function* () {
204 const { dirs, fs, path } = yield* makeTestContext();
205 const commands: Array<RecordedCommand> = [];
206 const service = yield* BootService.make({
207 baseDir: dirs.baseDir,
208 logsDir: dirs.logsDir,
209 cliVersion: "0.0.27",
210 host: makeHost(dirs.stableEntry),
211 }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
212
213 const plan = yield* service.install;
214
215 // A stable entry point is reused directly — no npm install.
216 assert.equal(plan.t3EntryPath, dirs.stableEntry);
217 assert.deepEqual(
218 commands.map((entry) => [entry.command, ...entry.args].join(" ")),
219 [
220 "systemctl --user daemon-reload",
221 "systemctl --user enable t3code.service",
222 // restart (not enable --now) so repairing a stale unit replaces a
223 // running process instead of leaving the old one until reboot.
224 "systemctl --user restart t3code.service",
225 "loginctl enable-linger",
226 ],
227 );
228
229 const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service");
230 const unit = yield* fs.readFileString(unitPath);
231 assert.include(unit, `ExecStart=/usr/local/bin/node ${dirs.stableEntry} serve`);
232 assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`);
233
234 const status = yield* service.status;
235 assert.isTrue(status.supported);
236 assert.isTrue(status.installed);
237 assert.isTrue(status.current);
238
239 const removed = yield* service.uninstall;
240 assert.isTrue(removed);
241 assert.isFalse(yield* fs.exists(unitPath));
242 const statusAfter = yield* service.status;
243 assert.isFalse(statusAfter.installed);
244 const removedAgain = yield* service.uninstall;
245 assert.isFalse(removedAgain);
246 }),
247 );
248
249 it.effect("pins a runtime via npm install when running from the npx cache", () =>
89it.layer(NodeServices.layer)("boot service install", (it) => {
90 it.effect("installs, reports current state, and uninstalls", () =>
25091 Effect.gen(function* () {
251 const { dirs, fs, path } = yield* makeTestContext();
252 const commands: Array<RecordedCommand> = [];
253 const service = yield* BootService.make({
254 baseDir: dirs.baseDir,
255 logsDir: dirs.logsDir,
256 cliVersion: "0.0.27",
257 host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"),
258 }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
259
92 const { service, fs, statePath, commands } = yield* makeHarness();
26093 const plan = yield* service.install;
26194
262 const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27");
263 assert.equal(
264 plan.t3EntryPath,
265 path.join(runtimeDir, "node_modules", "t3", "dist", "bin.mjs"),
266 );
267 assert.deepEqual(commands[0], {
268 command: "npm",
269 args: ["install", "--prefix", runtimeDir, "--no-fund", "--no-audit", "t3@0.0.27"],
95 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
96 protocol: 1,
97 activeVersion: "1.2.3",
27098 });
271 // Success is recorded via a sentinel so interrupted installs re-run.
272 assert.isTrue(yield* fs.exists(path.join(runtimeDir, ".install-complete")));
273 }),
274 );
275
276 it.effect("reinstalls a pinned runtime when its entry point is missing", () =>
277 Effect.gen(function* () {
278 const { dirs, fs, path } = yield* makeTestContext();
279 const commands: Array<RecordedCommand> = [];
280 const service = yield* BootService.make({
281 baseDir: dirs.baseDir,
282 logsDir: dirs.logsDir,
283 cliVersion: "0.0.27",
284 host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"),
285 }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
286
287 const plan = yield* service.install;
288 yield* fs.makeDirectory(path.dirname(plan.t3EntryPath), { recursive: true });
289 yield* fs.writeFileString(plan.t3EntryPath, "#!/usr/bin/env node\n");
290 yield* fs.remove(plan.t3EntryPath);
291 commands.length = 0;
292
293 yield* service.install;
294
295 assert.isTrue(commands.some(({ command }) => command === "npm"));
296 }),
297 );
298
299 it.effect("reads executable metadata from host process references", () =>
300 Effect.gen(function* () {
301 const { dirs } = yield* makeTestContext();
302 const commands: Array<RecordedCommand> = [];
303 const service = yield* BootService.make({
304 baseDir: dirs.baseDir,
305 logsDir: dirs.logsDir,
306 cliVersion: "0.0.27",
307 }).pipe(
308 Effect.provide(makeRecordingRunnerLayer(commands)),
309 provideHostRefs(dirs.home),
310 Effect.provideService(HostProcessExecutablePath, "/opt/node/bin/node"),
311 Effect.provideService(HostProcessArguments, ["/opt/node/bin/node", dirs.stableEntry]),
312 );
313
314 const plan = yield* service.install;
315 assert.equal(plan.nodePath, "/opt/node/bin/node");
316 assert.equal(plan.t3EntryPath, dirs.stableEntry);
317 }),
318 );
319
320 it.effect("cleans up and fails when the pinned runtime install fails", () =>
321 Effect.gen(function* () {
322 const { dirs, fs, path } = yield* makeTestContext();
323 const commands: Array<RecordedCommand> = [];
324 const service = yield* BootService.make({
325 baseDir: dirs.baseDir,
326 logsDir: dirs.logsDir,
327 cliVersion: "0.0.27",
328 host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"),
329 }).pipe(
330 Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "npm" })),
331 provideHostRefs(dirs.home),
332 );
333
334 const error = yield* service.install.pipe(Effect.flip);
335 assert.isTrue(isCommandError(error));
336 const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27");
337 // The half-installed tree must not be reused by the next attempt.
338 assert.isFalse(yield* fs.exists(runtimeDir));
339 assert.isFalse(yield* fs.exists(path.join(runtimeDir, ".install-complete")));
340 }),
341 );
342
343 it.effect("reports an installed-but-stale unit so the lifecycle can offer a repair", () =>
344 Effect.gen(function* () {
345 const { dirs, fs, path } = yield* makeTestContext();
346 const commands: Array<RecordedCommand> = [];
347 const service = yield* BootService.make({
348 baseDir: dirs.baseDir,
349 logsDir: dirs.logsDir,
350 cliVersion: "0.0.27",
351 host: makeHost(dirs.stableEntry),
352 }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
353
354 const unitDir = path.join(dirs.home, ".config", "systemd", "user");
355 yield* fs.makeDirectory(unitDir, { recursive: true });
99 expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");
100 expect((yield* service.status).current).toBe(true);
356101 yield* fs.writeFileString(
357 path.join(unitDir, "t3code.service"),
358 "[Service]\nExecStart=/old/node /old/t3 serve\n",
102 statePath,
103 '{"protocol":1,"activeVersion":"1.2.3","update":{"id":"u","fromVersion":"1.2.3","targetVersion":"1.2.4","status":"pending"}}',
359104 );
360
361 const status = yield* service.status;
362 assert.isTrue(status.supported);
363 assert.isTrue(status.installed);
364 assert.isFalse(status.current);
105 expect((yield* service.status).current).toBe(false);
106 expect(yield* service.uninstall).toBe(true);
107 expect((yield* service.status).installed).toBe(false);
108 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
365109 }),
366110 );
367111
368 it.effect("reports a current unit as stale when its entry point is gone", () =>
112 it.effect("restarts an installed service when repair fails", () =>
369113 Effect.gen(function* () {
370 const { dirs, fs } = yield* makeTestContext();
371 const commands: Array<RecordedCommand> = [];
372 const service = yield* BootService.make({
373 baseDir: dirs.baseDir,
374 logsDir: dirs.logsDir,
375 cliVersion: "0.0.27",
376 host: makeHost(dirs.stableEntry),
377 }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
378
114 const { service, commands, control } = yield* makeHarness();
379115 yield* service.install;
380 assert.isTrue((yield* service.status).current);
381
382 // The pinned runtime (or global bin) was deleted to reclaim space; the
383 // unit still matches byte-for-byte but would crashloop at boot.
384 yield* fs.remove(dirs.stableEntry);
385 const status = yield* service.status;
386 assert.isTrue(status.installed);
387 assert.isFalse(status.current);
388 }),
389 );
390
391 it.effect("fails on non-Linux platforms without touching the filesystem", () =>
392 Effect.gen(function* () {
393 const { dirs, fs, path } = yield* makeTestContext();
394 const commands: Array<RecordedCommand> = [];
395 const service = yield* BootService.make({
396 baseDir: dirs.baseDir,
397 logsDir: dirs.logsDir,
398 cliVersion: "0.0.27",
399 host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
400 }).pipe(
401 Effect.provide(makeRecordingRunnerLayer(commands)),
402 provideHostRefs(dirs.home, "darwin"),
403 );
404
405 const error = yield* service.install.pipe(Effect.flip);
406 assert.isTrue(isUnsupportedError(error));
407 assert.lengthOf(commands, 0);
408 assert.isFalse(
409 yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
410 );
411
412 const status = yield* service.status;
413 assert.isFalse(status.supported);
414 assert.isFalse(status.installed);
415 }),
416 );
417
418 it.effect("removes the unit file when an activation step fails", () =>
419 Effect.gen(function* () {
420 const { dirs, fs, path } = yield* makeTestContext();
421 const commands: Array<RecordedCommand> = [];
422 const service = yield* BootService.make({
423 baseDir: dirs.baseDir,
424 logsDir: dirs.logsDir,
425 cliVersion: "0.0.27",
426 host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
427 }).pipe(
428 Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "loginctl" })),
429 provideHostRefs(dirs.home),
430 );
116 commands.length = 0;
117 control.failCommand = "systemctl --user daemon-reload";
431118
432119 const error = yield* service.install.pipe(Effect.flip);
433 assert.isTrue(isCommandError(error));
434 // A leftover unit would make status report "installed" even though
435 // linger never happened.
436 assert.isFalse(
437 yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
438 );
439 const status = yield* service.status;
440 assert.isFalse(status.installed);
441 assert.isTrue(
442 commands.some(
443 ({ command, args }) =>
444 command === "systemctl" && args.join(" ") === "--user disable --now t3code.service",
445 ),
446 );
120 expect(error._tag).toBe("BootServiceCommandError");
121 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([
122 "systemctl --user stop t3code.service",
123 "systemctl --user daemon-reload",
124 "systemctl --user restart t3code.service",
125 ]);
447126 }),
448127 );
449128
450 it.effect("restores the previous unit when a repair cannot activate", () =>
129 it.effect("fails closed off Linux", () =>
451130 Effect.gen(function* () {
452 const { dirs, fs, path } = yield* makeTestContext();
453 const initialCommands: Array<RecordedCommand> = [];
454 const initialService = yield* BootService.make({
455 baseDir: dirs.baseDir,
456 logsDir: dirs.logsDir,
457 cliVersion: "0.0.27",
458 host: makeHost(dirs.stableEntry),
459 }).pipe(
460 Effect.provide(makeRecordingRunnerLayer(initialCommands)),
461 provideHostRefs(dirs.home),
462 );
463 yield* initialService.install;
464
465 const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service");
466 const previousUnit = yield* fs.readFileString(unitPath);
467 const replacementEntry = path.join(dirs.home, "replacement-bin.mjs");
468 yield* fs.writeFileString(replacementEntry, "#!/usr/bin/env node\n");
469 const repairCommands: Array<RecordedCommand> = [];
470 const repairService = yield* BootService.make({
471 baseDir: dirs.baseDir,
472 logsDir: dirs.logsDir,
473 cliVersion: "0.0.28",
474 host: makeHost(replacementEntry),
475 }).pipe(
476 Effect.provide(makeRecordingRunnerLayer(repairCommands, { failCommand: "loginctl" })),
477 provideHostRefs(dirs.home),
478 );
479
480 const error = yield* repairService.install.pipe(Effect.flip);
481
482 assert.isTrue(isCommandError(error));
483 assert.equal(yield* fs.readFileString(unitPath), previousUnit);
484 assert.isTrue(
485 repairCommands.some(
486 ({ command, args }) =>
487 command === "systemctl" && args.join(" ") === "--user restart t3code.service",
488 ),
489 );
490 }),
491 );
492
493 it.effect("keeps the unit when stopping it during uninstall fails", () =>
494 Effect.gen(function* () {
495 const { dirs, fs, path } = yield* makeTestContext();
496 const installCommands: Array<RecordedCommand> = [];
497 const installedService = yield* BootService.make({
498 baseDir: dirs.baseDir,
499 logsDir: dirs.logsDir,
500 cliVersion: "0.0.27",
501 host: makeHost(dirs.stableEntry),
502 }).pipe(
503 Effect.provide(makeRecordingRunnerLayer(installCommands)),
504 provideHostRefs(dirs.home),
505 );
506 yield* installedService.install;
507
508 const uninstallCommands: Array<RecordedCommand> = [];
509 const failingService = yield* BootService.make({
510 baseDir: dirs.baseDir,
511 logsDir: dirs.logsDir,
512 cliVersion: "0.0.27",
513 host: makeHost(dirs.stableEntry),
514 }).pipe(
515 Effect.provide(
516 makeRecordingRunnerLayer(uninstallCommands, {
517 failWhen: (command, args) =>
518 command === "systemctl" && args.includes("disable") && args.includes("--now"),
519 }),
520 ),
521 provideHostRefs(dirs.home),
522 );
523
524 const error = yield* failingService.uninstall.pipe(Effect.flip);
525
526 assert.isTrue(isCommandError(error));
527 assert.isTrue(
528 yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
529 );
530 }),
531 );
532
533 it.effect("appends failed steps to the boot-service log", () =>
534 Effect.gen(function* () {
535 const { dirs, fs, path } = yield* makeTestContext();
536 const commands: Array<RecordedCommand> = [];
537 const service = yield* BootService.make({
538 baseDir: dirs.baseDir,
539 logsDir: dirs.logsDir,
540 cliVersion: "0.0.27",
541 host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
542 }).pipe(
543 Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "systemctl" })),
544 provideHostRefs(dirs.home),
545 );
546
547 const error = yield* service.install.pipe(Effect.flip);
548 assert.isTrue(isCommandError(error));
549 if (!isCommandError(error)) return;
550 assert.equal(error.exitCode, 1);
551 assert.equal(error.stderrLength, "systemctl exploded".length);
552
553 const logPath = path.join(dirs.logsDir, "boot-service.log");
554 assert.isTrue(yield* fs.exists(logPath));
555 assert.include(yield* fs.readFileString(logPath), "exit code 1");
131 const { service } = yield* makeHarness("darwin");
132 expect((yield* service.status).supported).toBe(false);
133 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError");
556134 }),
557135 );
558136});
apps/server/src/cloud/bootService.ts+163-188
......@@ -1,5 +1,10 @@
1import * as Context from "effect/Context";
1import {
2 HostProcessArguments,
3 HostProcessExecutablePath,
4 HostProcessPlatform,
5} from "@t3tools/shared/hostProcess";
26import * as Config from "effect/Config";
7import * as Context from "effect/Context";
38import * as DateTime from "effect/DateTime";
49import * as Duration from "effect/Duration";
510import * as Effect from "effect/Effect";
......@@ -9,58 +14,29 @@ import * as Option from "effect/Option";
914import * as Path from "effect/Path";
1015import * as Schema from "effect/Schema";
1116
12import {
13 HostProcessArguments,
14 HostProcessExecutablePath,
15 HostProcessPlatform,
16} from "@t3tools/shared/hostProcess";
17
1817import * as ProcessRunner from "../processRunner.ts";
19import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts";
20
21/**
22 * Installs T3 Code as a per-user boot service. Linux-only for now: systemd
23 * user unit + loginctl enable-linger. The service runs a stable or pinned
24 * runtime — never an ephemeral `npx t3` cache whose eviction could break
25 * startup.
26 */
18import {
19 ensurePinnedRuntimeInstalled,
20 pinnedRuntimePaths,
21 PinnedRuntimeInstallError,
22} from "./pinnedRuntime.ts";
23import {
24 SERVICE_LAUNCHER_FILE,
25 SERVICE_LAUNCHER_PROTOCOL,
26 SERVICE_STATE_FILE,
27 parseServiceState,
28 type ServiceState,
29} from "./serviceProtocol.ts";
2730
2831const BOOT_SERVICE_NAME = "t3code";
29
3032export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
3133export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";
3234
33const EPHEMERAL_CACHE_SEGMENTS = [
34 "/_npx/", // npx
35 "\\_npx\\",
36 "/pnpm/dlx/", // pnpm dlx (~/.cache/pnpm/dlx and $PNPM_HOME/.pnpm/dlx)
37 "/.pnpm/dlx/",
38 "/.bun/install/cache/", // bunx
39];
40
41/**
42 * `npx t3` (and pnpm dlx / bunx) run out of ephemeral package-manager
43 * caches that can be evicted at any time — a boot service must never point
44 * there. Global installs, repo checkouts, and the pinned runtime below are
45 * all stable.
46 */
47export function isEphemeralCacheEntry(entryPath: string): boolean {
48 return EPHEMERAL_CACHE_SEGMENTS.some((segment) => entryPath.includes(segment));
49}
50
51/**
52 * systemd expands `%` specifiers in most directive values, including the
53 * `append:` file paths, which take the rest of the line literally and must
54 * NOT be quoted.
55 */
35/** systemd expands `%` specifiers, including in unquoted append-log paths. */
5636export function escapeSystemdSpecifiers(value: string): string {
5737 return value.replaceAll("%", "%%");
5838}
5939
60/**
61 * systemd word-splits ExecStart and Environment values and expands `%`
62 * specifiers, so paths with spaces or percents must be quoted and escaped.
63 */
6440export function quoteSystemdValue(value: string): string {
6541 const escaped = escapeSystemdSpecifiers(value);
6642 return /[\s"'\\]/.test(escaped)
......@@ -69,31 +45,19 @@ export function quoteSystemdValue(value: string): string {
6945}
7046
7147export interface BootServicePlan {
72 /** Absolute path of the node binary running this CLI. */
7348 readonly nodePath: string;
74 /** Absolute path of the pinned t3 entry point the unit will run. */
75 readonly t3EntryPath: string;
49 readonly launcherPath: string;
7650 readonly baseDir: string;
7751 readonly logPath: string;
7852 readonly unitPath: string;
7953}
8054
81/**
82 * Pure so it is testable byte-for-byte. systemd user units run with a
83 * minimal environment: every path must be absolute, and the service must
84 * not rely on PATH, nvm shims, or shell profiles. Failures land in
85 * `logPath` because `systemctl --user` failures are otherwise invisible.
86 */
55/** Pure renderer: service units cannot rely on the user's shell or PATH. */
8756export function renderBootServiceUnit(plan: BootServicePlan): string {
88 // No After=network-online.target: it does not exist in the systemd *user*
89 // manager, so ordering on it is silently ignored. The server retries its
90 // relay connection, and Restart=always covers early-boot failures.
57 // The user manager has no reliable network-online target; server networking retries itself.
9158 return [
9259 "[Unit]",
9360 "Description=T3 Code server",
94 // Give up after 5 crashes in 5 minutes so a persistently broken install
95 // (deleted runtime, broken workspace) stops instead of restarting every
96 // 5s forever and growing the unrotated append log without bound.
9761 "StartLimitIntervalSec=300",
9862 "StartLimitBurst=5",
9963 "",
......@@ -102,7 +66,8 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
10266 "WorkingDirectory=%h",
10367 `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
10468 `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
105 `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`,
69 `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`,
70 "KillMode=control-group",
10671 "Restart=always",
10772 "RestartSec=5",
10873 `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`,
......@@ -157,7 +122,6 @@ export type BootServiceError =
157122export interface BootServiceStatus {
158123 readonly supported: boolean;
159124 readonly installed: boolean;
160 /** False when the installed unit no longer matches what install would write. */
161125 readonly current: boolean;
162126 readonly unitPath: string;
163127 readonly logPath: string;
......@@ -166,12 +130,7 @@ export interface BootServiceStatus {
166130export class BootService extends Context.Service<
167131 BootService,
168132 {
169 /** Installs the pinned runtime + unit, enables linger, starts the service. */
170133 readonly install: Effect.Effect<BootServicePlan, BootServiceError>;
171 /**
172 * Stops and removes the unit; leaves the pinned runtime for reuse.
173 * Returns whether a unit was actually removed.
174 */
175134 readonly uninstall: Effect.Effect<boolean, BootServiceError>;
176135 readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;
177136 }
......@@ -180,6 +139,7 @@ export class BootService extends Context.Service<
180139export interface BootServiceHost {
181140 readonly execPath: string;
182141 readonly cliEntryPath: string;
142 readonly launcherSourcePath?: string;
183143}
184144
185145export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
......@@ -188,24 +148,45 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
188148 readonly cliVersion: string;
189149 readonly host?: BootServiceHost;
190150}) {
191 const hostExecPath = yield* HostProcessExecutablePath;
192151 const hostArguments = yield* HostProcessArguments;
193 const host = input.host ?? {
194 execPath: hostExecPath,
195 // When running the packed CLI this is dist/bin.mjs; when stable (global
196 // install, repo checkout) the boot service runs this same artifact.
197 cliEntryPath: hostArguments[1] ?? "",
198 };
152 const hostExecPath = yield* HostProcessExecutablePath;
199153 const platform = yield* HostProcessPlatform;
200154 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
201155 const fs = yield* FileSystem.FileSystem;
202156 const path = yield* Path.Path;
203157 const runner = yield* ProcessRunner.ProcessRunner;
158 const host = input.host ?? {
159 execPath: hostExecPath,
160 cliEntryPath: hostArguments[1] ?? "",
161 };
204162
205163 const unitDir = path.join(homeDir, ".config", "systemd", "user");
206164 const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);
207165 const logPath = path.join(input.logsDir, "boot-service.log");
166 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);
167 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
168 const launcherSourcePath =
169 host.launcherSourcePath ?? path.join(path.dirname(host.cliEntryPath), SERVICE_LAUNCHER_FILE);
208170 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion);
171 const writeDurably = (filePath: string, contents: string) =>
172 Effect.scoped(
173 Effect.gen(function* () {
174 const directory = path.dirname(filePath);
175 yield* fs.makeDirectory(directory, { recursive: true });
176 const tempPath = yield* fs.makeTempFileScoped({ directory, prefix: ".service-write-" });
177 yield* fs.writeFileString(tempPath, contents, { mode: 0o600 });
178 yield* (yield* fs.open(tempPath, { flag: "r" })).sync;
179 yield* fs.rename(tempPath, filePath);
180 yield* (yield* fs.open(directory, { flag: "r" })).sync;
181 }),
182 ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
183 const plan: BootServicePlan = {
184 nodePath: host.execPath,
185 launcherPath,
186 baseDir: input.baseDir,
187 logPath,
188 unitPath,
189 };
209190
210191 const requireSystemdLinux = Effect.gen(function* () {
211192 if (platform !== "linux" || homeDir === "") {
......@@ -244,150 +225,130 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
244225 );
245226 });
246227
247 /**
248 * Ensures plannedEntryPath exists before the unit points at it. A stable
249 * install (global bin, repo checkout) is used as-is; an ephemeral cache
250 * entry is replaced by `npm install --prefix`-ing the exact running
251 * version into <baseDir>/runtime/versions/<v>. A real install (not a copy
252 * of bin.mjs) because t3 ships native deps like node-pty.
253 */
254 const ensurePinnedRuntime = Effect.gen(function* () {
255 if (!isEphemeralCacheEntry(host.cliEntryPath)) {
256 return;
257 }
228 const install: BootService["Service"]["install"] = Effect.gen(function* () {
229 yield* requireSystemdLinux;
230 yield* fs
231 .makeDirectory(input.logsDir, { recursive: true })
232 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
233
234 // Prepare every immutable artifact before stopping the installed unit.
258235 yield* ensurePinnedRuntimeInstalled({
259236 baseDir: input.baseDir,
260237 version: input.cliVersion,
261238 fs,
262239 path,
263240 runner,
241 validate: (runtime) =>
242 runner
243 .run({
244 command: host.execPath,
245 args: [runtime.entryPath, "--version"],
246 timeout: Duration.seconds(30),
247 })
248 .pipe(
249 Effect.mapError(
250 (cause) =>
251 new PinnedRuntimeInstallError({
252 step: "verifying the pinned t3 runtime",
253 cause,
254 }),
255 ),
256 Effect.flatMap((result) => {
257 const reportedVersion = /\bv(\S+)\s*$/.exec(result.stdout)?.[1];
258 return result.code === 0 && reportedVersion === input.cliVersion
259 ? Effect.void
260 : Effect.fail(
261 new PinnedRuntimeInstallError({
262 step: "verifying the pinned t3 runtime",
263 exitCode: Number(result.code),
264 stdoutLength: result.stdout.length,
265 stderrLength: result.stderr.length,
266 }),
267 );
268 }),
269 ),
264270 }).pipe(
265271 Effect.mapError((error) =>
266 error.step.startsWith("installing")
272 error._tag === "PinnedRuntimeInstallError"
267273 ? new BootServiceCommandError({
268274 step: error.step,
269275 exitCode: error.exitCode,
270276 stdoutLength: error.stdoutLength,
271277 stderrLength: error.stderrLength,
272 cause: error.cause,
278 cause: error,
273279 })
274280 : new BootServiceInstallError({ cause: error }),
275281 ),
276 Effect.tapError((error) =>
277 DateTime.now.pipe(
278 Effect.flatMap((now) =>
279 fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, {
280 flag: "a",
281 }),
282 ),
283 Effect.ignore,
284 ),
285 ),
286282 );
287 });
288
289 // Where the unit will point: derivable without touching the network, so
290 // status can compare units purely; install materializes it first.
291 const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath)
292 ? runtimePaths.entryPath
293 : host.cliEntryPath;
294 const plan: BootServicePlan = {
295 nodePath: host.execPath,
296 t3EntryPath: plannedEntryPath,
297 baseDir: input.baseDir,
298 logPath,
299 unitPath,
300 };
301
302 const install: BootService["Service"]["install"] = Effect.gen(function* () {
303 yield* requireSystemdLinux;
304 yield* fs
305 .makeDirectory(input.logsDir, { recursive: true })
283 const launcherSource = yield* fs
284 .readFileString(launcherSourcePath)
306285 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
307286
308 yield* ensurePinnedRuntime;
309
310 const previousUnit = yield* fs.exists(unitPath).pipe(
311 Effect.flatMap((exists) =>
312 exists
313 ? fs.readFileString(unitPath).pipe(Effect.map(Option.some))
314 : Effect.succeed(Option.none<string>()),
315 ),
316 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
317 );
318
319 yield* fs.makeDirectory(unitDir, { recursive: true }).pipe(
320 Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))),
321 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
322 );
287 const installed = yield* fs
288 .exists(unitPath)
289 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
290 if (installed) {
291 yield* runStep("stopping the installed service", "systemctl", [
292 "--user",
293 "stop",
294 BOOT_SERVICE_UNIT_FILE,
295 ]);
296 }
323297
324 // If any activation step fails, remove the unit again: a leftover file
325 // would make service status report it as installed even though it was
326 // never enabled or lingered.
327298 yield* Effect.gen(function* () {
299 yield* fs
300 .makeDirectory(unitDir, { recursive: true })
301 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
302 yield* writeDurably(launcherPath, launcherSource);
303 yield* writeDurably(
304 statePath,
305 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document.
306 `${JSON.stringify(
307 {
308 protocol: SERVICE_LAUNCHER_PROTOCOL,
309 activeVersion: input.cliVersion,
310 } satisfies ServiceState,
311 null,
312 2,
313 )}\n`,
314 );
315 yield* writeDurably(unitPath, renderBootServiceUnit(plan));
316
328317 yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);
329318 yield* runStep("enabling the service", "systemctl", [
330319 "--user",
331320 "enable",
332321 BOOT_SERVICE_UNIT_FILE,
333322 ]);
334 // restart rather than enable --now: --now does not replace an already
335 // running process, so repairing a stale unit would leave the old
336 // server running until reboot. restart also starts a stopped service.
323 yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]);
324 // Start last. No administrative state write occurs after this succeeds.
337325 yield* runStep("starting the service", "systemctl", [
338326 "--user",
339327 "restart",
340328 BOOT_SERVICE_UNIT_FILE,
341329 ]);
342 // Linger keeps the user manager (and this service) running without an
343 // open session — the whole point on a box reached over SSH. No
344 // username argument: loginctl defaults to the calling user, which is
345 // always right, while $USER can be stale (su without -l) or unset.
346 yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]);
347 }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit)));
348
330 }).pipe(
331 Effect.tapError(() =>
332 installed
333 ? runStep("restarting the service after a failed update", "systemctl", [
334 "--user",
335 "restart",
336 BOOT_SERVICE_UNIT_FILE,
337 ]).pipe(Effect.ignore)
338 : Effect.void,
339 ),
340 );
349341 return plan;
350342 }).pipe(Effect.withSpan("cloud.boot_service.install"));
351343
352 // If activation fails partway (e.g. enable succeeds but restart/linger
353 // fails), leave nothing behind: disable removes the enable symlink, remove
354 // deletes the file, daemon-reload clears the stale definition — otherwise a
355 // dangling wants/ symlink logs "Failed to load unit" at every boot and the
356 // next lifecycle command misreports the state.
357 const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* (
358 previousUnit: Option.Option<string>,
359 ) {
360 if (Option.isSome(previousUnit)) {
361 yield* fs.writeFileString(unitPath, previousUnit.value).pipe(Effect.ignore);
362 } else {
363 yield* runStep("cleaning up the service", "systemctl", [
364 "--user",
365 "disable",
366 "--now",
367 BOOT_SERVICE_UNIT_FILE,
368 ]).pipe(Effect.ignore);
369 yield* fs.remove(unitPath).pipe(Effect.ignore);
370 }
371 yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]).pipe(
372 Effect.ignore,
373 );
374 if (Option.isSome(previousUnit)) {
375 yield* runStep("restoring the previous service", "systemctl", [
376 "--user",
377 "restart",
378 BOOT_SERVICE_UNIT_FILE,
379 ]).pipe(Effect.ignore);
380 }
381 });
382
383344 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
384345 yield* requireSystemdLinux;
385 const exists = yield* fs
386 .exists(unitPath)
387 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
388 if (!exists) {
346 if (
347 !(yield* fs
348 .exists(unitPath)
349 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))))
350 )
389351 return false;
390 }
391352 yield* runStep("stopping the service", "systemctl", [
392353 "--user",
393354 "disable",
......@@ -405,18 +366,32 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
405366 if (platform !== "linux" || homeDir === "") {
406367 return { supported: false, installed: false, current: false, unitPath, logPath };
407368 }
408 const unitExists = yield* fs.exists(unitPath);
409 if (!unitExists) {
369 if (!(yield* fs.exists(unitPath))) {
410370 return { supported: true, installed: false, current: false, unitPath, logPath };
411371 }
412 const unit = yield* fs.readFileString(unitPath);
413 // A unit is current only if it matches what install would write now (an
414 // older CLI wrote a different runtime/node path) AND the entry point it
415 // references still exists (a pinned runtime under ~/.t3 can be deleted to
416 // reclaim space). Either mismatch makes connect offer a repair.
417 const entryExists = yield* fs.exists(plannedEntryPath);
418 const current = unit === renderBootServiceUnit(plan) && entryExists;
419 return { supported: true, installed: true, current, unitPath, logPath };
372 const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] =
373 yield* Effect.all([
374 fs.readFileString(unitPath),
375 fs.exists(launcherPath),
376 fs.exists(runtimePaths.entryPath),
377 fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option),
378 fs.readFileString(statePath).pipe(Effect.option),
379 ]);
380 const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
381 return {
382 supported: true,
383 installed: true,
384 current:
385 unit === renderBootServiceUnit(plan) &&
386 launcherExists &&
387 runtimeEntryExists &&
388 Option.isSome(runtimeSentinel) &&
389 runtimeSentinel.value.trim() === input.cliVersion &&
390 state?.activeVersion === input.cliVersion &&
391 state?.update?.status !== "pending",
392 unitPath,
393 logPath,
394 };
420395 }).pipe(
421396 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
422397 Effect.withSpan("cloud.boot_service.status"),
apps/server/src/cloud/pinnedRuntime.test.ts+133-88
......@@ -2,8 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, it } from "@effect/vitest";
33import * as Deferred from "effect/Deferred";
44import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
65import * as FileSystem from "effect/FileSystem";
6import * as Fiber from "effect/Fiber";
77import * as Path from "effect/Path";
88import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
99
......@@ -11,119 +11,164 @@ import * as ProcessRunner from "../processRunner.ts";
1111import {
1212 ensurePinnedRuntimeInstalled,
1313 pinnedRuntimePaths,
14 removePinnedRuntimeInstallation,
14 PinnedRuntimeInstallError,
1515} from "./pinnedRuntime.ts";
1616
17const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) =>
18 ProcessRunner.ProcessRunner.of({
19 run: (input) =>
20 Effect.gen(function* () {
21 const prefixIndex = input.args.indexOf("--prefix");
22 const stagingDir = input.args[prefixIndex + 1];
23 if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix");
24 const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs");
25 yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie);
26 yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie);
27 return {
28 stdout: "",
29 stderr: "",
30 code: ChildProcessSpawner.ExitCode(0),
31 timedOut: false,
32 stdoutTruncated: false,
33 stderrTruncated: false,
34 };
35 }),
36 });
37
1738it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
18 it.effect("serializes concurrent installs of the same runtime", () =>
39 it.effect("validates a staging tree before atomically publishing it", () =>
1940 Effect.gen(function* () {
2041 const fs = yield* FileSystem.FileSystem;
2142 const path = yield* Path.Path;
2243 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
23 const installStarted = yield* Deferred.make<void>();
24 const allowInstallToFinish = yield* Deferred.make<void>();
25 const paths = pinnedRuntimePaths(path, baseDir, "0.0.29");
26 let npmRuns = 0;
44 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3");
45 let validatedDirectory = "";
2746
28 const runner = ProcessRunner.ProcessRunner.of({
29 run: (_input) =>
47 const installed = yield* ensurePinnedRuntimeInstalled({
48 baseDir,
49 version: "1.2.3",
50 fs,
51 path,
52 runner: successfulRunner(fs, path),
53 validate: (staging) =>
3054 Effect.gen(function* () {
31 npmRuns += 1;
32 yield* Deferred.succeed(installStarted, undefined);
33 yield* Deferred.await(allowInstallToFinish);
34 yield* fs
35 .makeDirectory(path.dirname(paths.entryPath), { recursive: true })
36 .pipe(Effect.orDie);
37 yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie);
38 return {
39 stdout: "",
40 stderr: "",
41 code: ChildProcessSpawner.ExitCode(0),
42 timedOut: false,
43 stdoutTruncated: false,
44 stderrTruncated: false,
45 };
46 }),
55 validatedDirectory = staging.versionDir;
56 assert.isFalse(yield* fs.exists(finalPaths.versionDir));
57 assert.isTrue(yield* fs.exists(staging.entryPath));
58 }).pipe(Effect.orDie),
4759 });
48 const install = ensurePinnedRuntimeInstalled({
60
61 assert.notEqual(validatedDirectory, finalPaths.versionDir);
62 assert.deepEqual(installed, finalPaths);
63 assert.isTrue(yield* fs.exists(finalPaths.entryPath));
64 assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n");
65 }),
66 );
67
68 it.effect("removes staging and leaves no final runtime when validation fails", () =>
69 Effect.gen(function* () {
70 const fs = yield* FileSystem.FileSystem;
71 const path = yield* Path.Path;
72 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
73 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3");
74
75 yield* ensurePinnedRuntimeInstalled({
4976 baseDir,
50 version: "0.0.29",
77 version: "1.2.3",
5178 fs,
5279 path,
53 runner,
54 });
80 runner: successfulRunner(fs, path),
81 validate: () =>
82 Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })),
83 }).pipe(Effect.flip);
5584
56 const first = yield* Effect.forkChild(install, { startImmediately: true });
57 yield* Deferred.await(installStarted);
58 const second = yield* Effect.forkChild(install, { startImmediately: true });
59 yield* Effect.yieldNow;
60 assert.equal(npmRuns, 1);
85 assert.isFalse(yield* fs.exists(finalPaths.versionDir));
86 assert.deepEqual(
87 (yield* fs.readDirectory(path.dirname(finalPaths.versionDir))).filter((entry) =>
88 entry.startsWith(".staging-"),
89 ),
90 [],
91 );
92 }),
93 );
6194
62 yield* Deferred.succeed(allowInstallToFinish, undefined);
63 yield* Fiber.join(first);
64 yield* Fiber.join(second);
95 it.effect("replaces an incomplete pinned runtime", () =>
96 Effect.gen(function* () {
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });
100 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3");
101 yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true });
102 yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n");
65103
66 assert.equal(npmRuns, 1);
67 assert.isTrue(yield* fs.exists(paths.sentinelPath));
68 assert.isTrue(yield* fs.exists(paths.entryPath));
104 yield* ensurePinnedRuntimeInstalled({
105 baseDir,
106 version: "1.2.3",
107 fs,
108 path,
109 runner: successfulRunner(fs, path),
110 validate: () => Effect.void,
111 });
112
113 assert.isFalse(yield* fs.exists(path.join(finalPaths.versionDir, "partial")));
114 assert.isTrue(yield* fs.exists(finalPaths.entryPath));
69115 }),
70116 );
71117
72 it.effect("waits for an active install before removing its runtime", () =>
118 it.effect("preserves a completed runtime when validation fails", () =>
73119 Effect.gen(function* () {
74120 const fs = yield* FileSystem.FileSystem;
75121 const path = yield* Path.Path;
76 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
77 const installStarted = yield* Deferred.make<void>();
78 const allowInstallToFinish = yield* Deferred.make<void>();
79 const paths = pinnedRuntimePaths(path, baseDir, "0.0.30");
80 const runner = ProcessRunner.ProcessRunner.of({
81 run: (_input) =>
122 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });
123 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3");
124 yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true });
125 yield* fs.writeFileString(finalPaths.entryPath, "broken\n");
126 yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n");
127
128 let validations = 0;
129 yield* ensurePinnedRuntimeInstalled({
130 baseDir,
131 version: "1.2.3",
132 fs,
133 path,
134 runner: successfulRunner(fs, path),
135 validate: (paths) =>
82136 Effect.gen(function* () {
83 yield* Deferred.succeed(installStarted, undefined);
84 yield* Deferred.await(allowInstallToFinish);
85 yield* fs
86 .makeDirectory(path.dirname(paths.entryPath), { recursive: true })
87 .pipe(Effect.orDie);
88 yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie);
89 return {
90 stdout: "",
91 stderr: "",
92 code: ChildProcessSpawner.ExitCode(0),
93 timedOut: false,
94 stdoutTruncated: false,
95 stderrTruncated: false,
96 };
137 validations += 1;
138 const source = yield* fs.readFileString(paths.entryPath).pipe(Effect.orDie);
139 if (source === "broken\n") {
140 return yield* new PinnedRuntimeInstallError({ step: "validating the runtime" });
141 }
97142 }),
98 });
143 }).pipe(Effect.flip);
99144
100 const installFiber = yield* Effect.forkChild(
101 ensurePinnedRuntimeInstalled({
102 baseDir,
103 version: "0.0.30",
104 fs,
105 path,
106 runner,
107 }),
108 { startImmediately: true },
109 );
110 yield* Deferred.await(installStarted);
111 const removeFiber = yield* Effect.forkChild(
112 removePinnedRuntimeInstallation({
113 baseDir,
114 version: "0.0.30",
115 fs,
116 path,
117 }),
118 { startImmediately: true },
119 );
120 yield* Effect.yieldNow;
121 assert.isTrue(yield* fs.exists(paths.versionDir));
145 assert.equal(validations, 1);
146 assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n");
147 }),
148 );
149
150 it.effect("removes staging when installation is interrupted", () =>
151 Effect.gen(function* () {
152 const fs = yield* FileSystem.FileSystem;
153 const path = yield* Path.Path;
154 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-interrupt-" });
155 const started = yield* Deferred.make<void>();
156 const runner = ProcessRunner.ProcessRunner.of({
157 run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
158 });
159 const install = yield* ensurePinnedRuntimeInstalled({
160 baseDir,
161 version: "1.2.3",
162 fs,
163 path,
164 runner,
165 validate: () => Effect.void,
166 }).pipe(Effect.forkScoped);
122167
123 yield* Deferred.succeed(allowInstallToFinish, undefined);
124 yield* Fiber.join(installFiber);
125 yield* Fiber.join(removeFiber);
126 assert.isFalse(yield* fs.exists(paths.versionDir));
168 yield* Deferred.await(started);
169 yield* Fiber.interrupt(install);
170 const versionsDir = path.join(baseDir, "runtime", "versions");
171 assert.deepEqual(yield* fs.readDirectory(versionsDir), []);
127172 }),
128173 );
129174});
apps/server/src/cloud/pinnedRuntime.ts+150-104
......@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
33import * as FileSystem from "effect/FileSystem";
44import * as Path from "effect/Path";
55import * as Schema from "effect/Schema";
6import * as Option from "effect/Option";
67import * as Semaphore from "effect/Semaphore";
78
89import * as ProcessRunner from "../processRunner.ts";
......@@ -11,15 +12,14 @@ import * as ProcessRunner from "../processRunner.ts";
1112 * A pinned runtime is an exact `t3@<version>` npm-installed into
1213 * <baseDir>/runtime/versions/<version>. The boot service points its systemd
1314 * unit here, and server self-update installs the target version here before
14 * switching over — never `npx t3`, whose cache is ephemeral and whose
15 * switching over, never `npx t3`, whose cache is ephemeral and whose
1516 * registry fetch at boot would make startup depend on the network.
1617 */
1718
1819const PINNED_RUNTIME_DIR = "runtime";
1920const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
20// Boot-service setup and remote self-update share this module but can be
21// constructed in separate layers. Serialize the complete check/install/
22// sentinel transaction across all callers in this process.
21// Boot-service setup and remote update can construct separate layers. Serialize
22// the complete install transaction across every caller in this process.
2323const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1);
2424
2525export interface PinnedRuntimePaths {
......@@ -58,119 +58,165 @@ export class PinnedRuntimeInstallError extends Schema.TaggedErrorClass<PinnedRun
5858 }
5959}
6060
61export class PinnedRuntimePreflightBlockedError extends Schema.TaggedErrorClass<PinnedRuntimePreflightBlockedError>()(
62 "PinnedRuntimePreflightBlockedError",
63 {
64 version: Schema.String,
65 reason: Schema.String,
66 },
67) {
68 override get message(): string {
69 return this.reason;
70 }
71}
72
6173/**
6274 * Installs `t3@<version>` into the pinned runtime directory unless a complete
6375 * install is already there, and returns its paths. The sentinel is written
64 * only after npm exits 0; checking the entry file alone is not enough — npm
76 * only after npm exits 0; checking the entry file alone is not enough. npm
6577 * extracts files before running native builds (node-pty), so a killed
6678 * install leaves a plausible-looking but broken tree behind.
6779 */
68export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensure_installed")(
69 function* (input: {
70 readonly baseDir: string;
71 readonly version: string;
72 readonly fs: FileSystem.FileSystem;
73 readonly path: Path.Path;
74 readonly runner: ProcessRunner.ProcessRunner["Service"];
75 }) {
76 const { fs, runner } = input;
77 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version);
80interface PinnedRuntimeInstallInput {
81 readonly baseDir: string;
82 readonly version: string;
83 readonly fs: FileSystem.FileSystem;
84 readonly path: Path.Path;
85 readonly runner: ProcessRunner.ProcessRunner["Service"];
86 readonly validate: (
87 paths: PinnedRuntimePaths,
88 ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>;
89}
90
91const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* (
92 input: PinnedRuntimeInstallInput,
93) {
94 const { fs, runner } = input;
95 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version);
96 const [versionDirExists, entryExists, sentinel] = yield* Effect.all([
97 fs.exists(paths.versionDir),
98 fs.exists(paths.entryPath),
99 fs.readFileString(paths.sentinelPath).pipe(Effect.option),
100 ]).pipe(
101 Effect.mapError(
102 (cause) => new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }),
103 ),
104 );
105 const alreadyPinned =
106 entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version;
107 if (alreadyPinned) {
108 yield* input.validate(paths);
109 return paths;
110 }
111 if (versionDirExists) {
112 yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(
113 Effect.mapError(
114 (cause) =>
115 new PinnedRuntimeInstallError({
116 step: "removing an incomplete pinned runtime",
117 cause,
118 }),
119 ),
120 );
121 }
122
123 const versionsDir = input.path.dirname(paths.versionDir);
124 yield* fs.makeDirectory(versionsDir, { recursive: true }).pipe(
125 Effect.mapError(
126 (cause) =>
127 new PinnedRuntimeInstallError({
128 step: "preparing the pinned runtime directory",
129 cause,
130 }),
131 ),
132 );
133 const stagingDir = yield* fs
134 .makeTempDirectory({
135 directory: versionsDir,
136 prefix: ".staging-",
137 })
138 .pipe(
139 Effect.mapError(
140 (cause) =>
141 new PinnedRuntimeInstallError({
142 step: "preparing the pinned runtime directory",
143 cause,
144 }),
145 ),
146 );
147 const stagingPaths: PinnedRuntimePaths = {
148 versionDir: stagingDir,
149 entryPath: input.path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"),
150 sentinelPath: input.path.join(stagingDir, ".install-complete"),
151 };
78152
79 return yield* pinnedRuntimeInstallLock.withPermit(
80 Effect.gen(function* () {
81 const alreadyPinned = yield* Effect.all([
82 fs.exists(paths.sentinelPath),
153 return yield* Effect.gen(function* () {
154 const installStep = "installing the pinned t3 runtime (this can take a few minutes)";
155 yield* runner
156 .run({
157 command: "npm",
158 args: ["install", "--prefix", stagingDir, "--no-fund", "--no-audit", `t3@${input.version}`],
159 // Native dependencies may compile from source on slower machines.
160 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
161 })
162 .pipe(
163 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })),
164 Effect.filterOrFail(
165 (result) => result.code === 0,
166 (result) =>
167 new PinnedRuntimeInstallError({
168 step: installStep,
169 exitCode: Number(result.code),
170 stdoutLength: result.stdout.length,
171 stderrLength: result.stderr.length,
172 }),
173 ),
174 );
175
176 yield* input.validate(stagingPaths);
177 yield* fs
178 .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`)
179 .pipe(
180 Effect.mapError(
181 (cause) =>
182 new PinnedRuntimeInstallError({ step: "recording the completed install", cause }),
183 ),
184 );
185 const published = yield* fs.rename(stagingDir, paths.versionDir).pipe(
186 Effect.as(true),
187 Effect.catch((cause) =>
188 Effect.all([
83189 fs.exists(paths.entryPath),
190 fs.readFileString(paths.sentinelPath).pipe(Effect.option),
84191 ]).pipe(
85 Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists),
86 Effect.mapError(
87 (cause) =>
88 new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }),
89 ),
90 );
91 if (alreadyPinned) {
92 return paths;
93 }
94
95 yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(
96 Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })),
97192 Effect.mapError(
98 (cause) =>
193 (checkCause) =>
99194 new PinnedRuntimeInstallError({
100 step: "preparing the pinned runtime directory",
101 cause,
195 step: "checking a concurrently published pinned runtime",
196 cause: checkCause,
102197 }),
103198 ),
104 );
105
106 const installStep = "installing the pinned t3 runtime (this can take a few minutes)";
107 yield* runner
108 .run({
109 command: "npm",
110 args: [
111 "install",
112 "--prefix",
113 paths.versionDir,
114 "--no-fund",
115 "--no-audit",
116 `t3@${input.version}`,
117 ],
118 // Native deps (node-pty) can compile from source on slow boxes; the
119 // ProcessRunner default of 60s would kill a healthy install.
120 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
121 })
122 .pipe(
123 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })),
124 Effect.filterOrFail(
125 (result) => result.code === 0,
126 (result) =>
127 new PinnedRuntimeInstallError({
128 step: installStep,
129 exitCode: Number(result.code),
130 stdoutLength: result.stdout.length,
131 stderrLength: result.stderr.length,
132 }),
133 ),
134 Effect.tapError(() =>
135 fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore),
136 ),
137 );
138
139 yield* fs
140 .writeFileString(paths.sentinelPath, `${input.version}\n`)
141 .pipe(
142 Effect.mapError(
143 (cause) =>
144 new PinnedRuntimeInstallError({ step: "recording the completed install", cause }),
145 ),
146 );
147
148 return paths;
149 }),
150 );
151 },
152);
153
154/** Removes one pinned runtime while holding the same process-wide lock used
155 * by install/check/sentinel work, so cleanup cannot race another caller that
156 * is materializing or reusing the runtime tree. */
157export const removePinnedRuntimeInstallation = Effect.fn("cloud.pinned_runtime.remove")(
158 function* (input: {
159 readonly baseDir: string;
160 readonly version: string;
161 readonly fs: FileSystem.FileSystem;
162 readonly path: Path.Path;
163 }) {
164 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version);
165 yield* pinnedRuntimeInstallLock.withPermit(
166 input.fs
167 .remove(paths.versionDir, { recursive: true, force: true })
168 .pipe(
169 Effect.mapError(
170 (cause) =>
171 new PinnedRuntimeInstallError({ step: "removing the pinned runtime", cause }),
199 Effect.flatMap(([publishedEntryExists, publishedSentinel]) =>
200 publishedEntryExists &&
201 Option.isSome(publishedSentinel) &&
202 publishedSentinel.value.trim() === input.version
203 ? Effect.succeed(false)
204 : Effect.fail(
205 new PinnedRuntimeInstallError({
206 step: "publishing the pinned runtime",
207 cause,
208 }),
209 ),
172210 ),
173211 ),
212 ),
174213 );
175 },
176);
214 if (!published) yield* input.validate(paths);
215 return paths;
216 }).pipe(
217 Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)),
218 );
219});
220
221export const ensurePinnedRuntimeInstalled = (input: PinnedRuntimeInstallInput) =>
222 pinnedRuntimeInstallLock.withPermit(installPinnedRuntime(input));
apps/server/src/cloud/selfUpdate.test.ts+103-591
......@@ -1,632 +1,144 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
2import { expect, it } from "@effect/vitest";
3import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
4import * as Deferred from "effect/Deferred";
45import * as Effect from "effect/Effect";
56import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Fiber from "effect/Fiber";
78import * as Path from "effect/Path";
8import * as TestClock from "effect/testing/TestClock";
99import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1010
11import {
12 HostProcessArguments,
13 HostProcessEnvironment,
14 HostProcessExecutablePath,
15 HostProcessPlatform,
16} from "@t3tools/shared/hostProcess";
17
1811import * as ServerConfig from "../config.ts";
1912import * as ProcessRunner from "../processRunner.ts";
20import {
21 BOOT_SERVICE_UNIT_ENV,
22 BOOT_SERVICE_UNIT_FILE,
23 renderBootServiceUnit,
24} from "./bootService.ts";
25import * as SelfUpdate from "./selfUpdate.ts";
26
27const NODE_PATH = "/usr/local/bin/node";
13import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
14import * as ServerSelfUpdate from "./selfUpdate.ts";
15
16interface HarnessOptions {
17 readonly mode?: "web" | "desktop";
18 readonly managed?: boolean;
19 readonly preflight?: "ready" | "blocked";
20 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
21}
2822
29const eventuallyFileString = Effect.fn("test.eventuallyFileString")(function* (
30 filePath: string,
31 expected: string,
23const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
24 options: HarnessOptions = {},
3225) {
3326 const fs = yield* FileSystem.FileSystem;
34 for (let iteration = 0; iteration < 1_000; iteration += 1) {
35 const contents = yield* fs.readFileString(filePath);
36 if (contents === expected) {
37 return;
38 }
39 // The rollback performs real filesystem I/O on a detached fiber, which
40 // advancing TestClock does not await.
41 yield* Effect.yieldNow;
42 }
43 return yield* Effect.die(new Error(`Expected file contents were not observed at ${filePath}.`));
44});
45
46const eventuallyTrue = Effect.fn("test.eventuallyTrue")(function* (predicate: () => boolean) {
47 for (let iteration = 0; iteration < 1_000; iteration += 1) {
48 if (predicate()) {
49 return;
50 }
51 yield* Effect.yieldNow;
52 }
53 return yield* Effect.die(new Error("Expected condition was not observed."));
54});
55
56interface RecordedCommand {
57 readonly command: string;
58 readonly args: ReadonlyArray<string>;
59}
60
61const makeRecordingRunnerLayer = (
62 commands: Array<RecordedCommand>,
63 options?: {
64 readonly failWhen?: ((command: string, args: ReadonlyArray<string>) => boolean) | undefined;
65 readonly stdoutFor?:
66 | ((command: string, args: ReadonlyArray<string>) => string | undefined)
67 | undefined;
68 },
69) =>
70 Layer.succeed(
71 ProcessRunner.ProcessRunner,
72 ProcessRunner.ProcessRunner.of({
73 run: (input) =>
74 Effect.sync(() => {
75 commands.push({ command: input.command, args: input.args });
76 const failed = options?.failWhen?.(input.command, input.args) === true;
77 const versionFromPath =
78 input.command === NODE_PATH && input.args[1] === "--version"
79 ? /[/\\]runtime[/\\]versions[/\\]([^/\\]+)/.exec(input.args[0] ?? "")?.[1]
80 : undefined;
27 const path = yield* Path.Path;
28 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
29 const order: string[] = [];
30 const runner = ProcessRunner.ProcessRunner.of({
31 run: (input) =>
32 Effect.gen(function* () {
33 if (input.command === "npm") {
34 order.push("install");
35 const prefix = input.args[input.args.indexOf("--prefix") + 1];
36 if (prefix === undefined) return yield* Effect.die("missing npm prefix");
37 const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs");
38 yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie);
39 yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie);
8140 return {
82 stdout:
83 options?.stdoutFor?.(input.command, input.args) ??
84 (versionFromPath === undefined ? "" : `t3 v${versionFromPath}\n`),
85 stderr: failed ? `${input.command} exploded` : "",
86 code: ChildProcessSpawner.ExitCode(failed ? 1 : 0),
41 stdout: "",
42 stderr: "",
43 code: ChildProcessSpawner.ExitCode(0),
8744 timedOut: false,
8845 stdoutTruncated: false,
8946 stderrTruncated: false,
9047 };
91 }),
92 }),
93 );
94
95const provideHostRefs = (input: {
96 readonly platform: NodeJS.Platform;
97 readonly env: NodeJS.ProcessEnv;
98 readonly entryPath: string;
99}) =>
100 Effect.provide(
101 Layer.mergeAll(
102 Layer.succeed(HostProcessPlatform, input.platform),
103 Layer.succeed(HostProcessEnvironment, input.env),
104 Layer.succeed(HostProcessExecutablePath, NODE_PATH),
105 Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]),
106 ),
107 );
108
109it("recognizes published npm artifacts as swappable entry points", () => {
110 assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs"));
111 assert.isTrue(
112 SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"),
113 );
114 assert.isTrue(
115 SelfUpdate.isPublishedCliEntry(
116 "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs",
117 ),
118 );
119 // Dev checkouts and the desktop bundle run apps/server/dist directly.
120 assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs"));
121 assert.isFalse(SelfUpdate.isPublishedCliEntry(""));
122});
123
124it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => {
125 const makeHome = Effect.fn("test.makeHome")(function* () {
126 const fs = yield* FileSystem.FileSystem;
127 const path = yield* Path.Path;
128 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
129 return { fs, path, home };
130 });
131
132 const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* (
133 home: string,
134 entryPath: string,
135 ) {
136 const fs = yield* FileSystem.FileSystem;
137 const path = yield* Path.Path;
138 const unitDir = path.join(home, ".config", "systemd", "user");
139 yield* fs.makeDirectory(unitDir, { recursive: true });
140 yield* fs.writeFileString(
141 path.join(unitDir, "t3code.service"),
142 renderBootServiceUnit({
143 nodePath: NODE_PATH,
144 t3EntryPath: entryPath,
145 baseDir: path.join(home, ".t3"),
146 logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"),
147 unitPath: path.join(unitDir, "t3code.service"),
48 }
49 order.push("preflight");
50 const result =
51 options.preflight === "blocked"
52 ? { status: "blocked", version: "1.1.0", reason: "local update required" }
53 : { status: "ready", version: "1.1.0", launcherProtocol: 1 };
54 return {
55 // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout.
56 stdout: JSON.stringify(result),
57 stderr: "",
58 code: ChildProcessSpawner.ExitCode(0),
59 timedOut: false,
60 stdoutTruncated: false,
61 stderrTruncated: false,
62 };
14863 }),
149 );
15064 });
151
152 it.effect("reports boot-service for the systemd-spawned unit process", () =>
153 Effect.gen(function* () {
154 const { home, path } = yield* makeHome();
155 const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
156 yield* writeUnitReferencing(home, entryPath);
157 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
158 desktopManaged: false,
159 }).pipe(
160 provideHostRefs({
161 platform: "linux",
162 env: {
163 HOME: home,
164 INVOCATION_ID: "abc123",
165 [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE,
166 },
167 entryPath,
168 }),
169 );
170 assert.equal(method, "boot-service");
171 }),
172 );
173
174 it.effect("does not claim a systemd process owned by another unit", () =>
175 Effect.gen(function* () {
176 const { home, path } = yield* makeHome();
177 const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
178 yield* writeUnitReferencing(home, entryPath);
179 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
180 desktopManaged: false,
181 }).pipe(
182 provideHostRefs({
183 platform: "linux",
184 env: { HOME: home, INVOCATION_ID: "abc123" },
185 entryPath,
186 }),
187 );
188 assert.isNull(method);
189 }),
190 );
191
192 it.effect("reports respawn for a manual run of the pinned artifact", () =>
193 Effect.gen(function* () {
194 const { home, path } = yield* makeHome();
195 const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
196 yield* writeUnitReferencing(home, entryPath);
197 // Same unit on disk, but no INVOCATION_ID: restarting the unit would
198 // not replace this process, so it must respawn itself instead.
199 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
200 desktopManaged: false,
201 }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath }));
202 assert.equal(method, "respawn");
203 }),
204 );
205
206 it.effect("reports respawn for a foreground npx artifact on darwin", () =>
207 Effect.gen(function* () {
208 const { home } = yield* makeHome();
209 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
210 desktopManaged: false,
211 }).pipe(
212 provideHostRefs({
213 platform: "darwin",
214 env: { HOME: home },
215 entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`,
216 }),
217 );
218 assert.equal(method, "respawn");
219 }),
220 );
221
222 it.effect("reports desktop-managed for desktop-supervised backends", () =>
223 Effect.gen(function* () {
224 const { home, path } = yield* makeHome();
225 // Desktop ownership wins over every process-shape heuristic: even a
226 // systemd-looking pinned artifact belongs to the app that spawned it.
227 const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
228 yield* writeUnitReferencing(home, entryPath);
229 const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
230 desktopManaged: true,
231 }).pipe(
232 provideHostRefs({
233 platform: "linux",
234 env: {
235 HOME: home,
236 INVOCATION_ID: "abc123",
237 [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE,
238 },
239 entryPath,
240 }),
241 );
242 assert.equal(method, "desktop-managed");
243 }),
244 );
245
246 it.effect("reports no method for dev checkouts and Windows", () =>
247 Effect.gen(function* () {
248 const { home } = yield* makeHome();
249 const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({
250 desktopManaged: false,
251 }).pipe(
252 provideHostRefs({
253 platform: "darwin",
254 env: { HOME: home },
255 entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`,
256 }),
257 );
258 assert.isNull(devMethod);
259 const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({
260 desktopManaged: false,
261 }).pipe(
262 provideHostRefs({
263 platform: "win32",
264 env: { HOME: home },
265 entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs",
266 }),
267 );
268 assert.isNull(windowsMethod);
269 }),
270 );
271});
272
273it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => {
274 interface RecordedSpawn {
275 readonly command: string;
276 readonly args: ReadonlyArray<string>;
277 }
278
279 const makeContext = Effect.fn("test.makeContext")(function* (options?: {
280 readonly platform?: NodeJS.Platform;
281 readonly bootService?: boolean;
282 readonly desktopManaged?: boolean;
283 readonly entryPath?: string;
284 readonly failWhen?: (command: string, args: ReadonlyArray<string>) => boolean;
285 readonly stdoutFor?: (command: string, args: ReadonlyArray<string>) => string | undefined;
286 readonly failSpawn?: boolean;
287 }) {
288 const fs = yield* FileSystem.FileSystem;
289 const path = yield* Path.Path;
290 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
291 const baseDir = path.join(home, ".t3");
292 const entryPath =
293 options?.entryPath ??
294 path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
295 const env: NodeJS.ProcessEnv =
296 options?.bootService === true
297 ? {
298 HOME: home,
299 INVOCATION_ID: "abc123",
300 [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE,
301 }
302 : { HOME: home };
303 if (options?.bootService === true) {
304 const unitDir = path.join(home, ".config", "systemd", "user");
305 yield* fs.makeDirectory(unitDir, { recursive: true });
306 yield* fs.writeFileString(
307 path.join(unitDir, "t3code.service"),
308 renderBootServiceUnit({
309 nodePath: NODE_PATH,
310 t3EntryPath: entryPath,
311 baseDir,
312 logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"),
313 unitPath: path.join(unitDir, "t3code.service"),
314 }),
315 );
316 }
317
318 const commands: Array<RecordedCommand> = [];
319 const spawns: Array<RecordedSpawn> = [];
320 let exited = 0;
321 // layerTest always reports mode "web"; desktop-managed contexts overlay
322 // the mode the desktop app's bootstrap envelope would set.
323 const configLayer =
324 options?.desktopManaged === true
325 ? Layer.effect(
326 ServerConfig.ServerConfig,
327 Effect.gen(function* () {
328 const config = yield* ServerConfig.ServerConfig;
329 return { ...config, mode: "desktop" as const };
330 }),
331 ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir)))
332 : ServerConfig.layerTest(home, baseDir);
333 const service = yield* SelfUpdate.make({
334 host: {
335 spawnDetached: (command, args) =>
336 Effect.sync(() => spawns.push({ command, args })).pipe(
337 Effect.andThen(
338 options?.failSpawn === true
339 ? Effect.fail(
340 new ProcessRunner.ProcessSpawnError({
341 command,
342 argumentCount: args.length,
343 cause: new Error("detached spawn failed"),
344 }),
345 )
346 : Effect.void,
347 ),
348 ),
349 exitProcess: () => {
350 exited += 1;
351 },
352 },
353 }).pipe(
354 Effect.provide(
355 Layer.mergeAll(
356 makeRecordingRunnerLayer(commands, {
357 failWhen: options?.failWhen,
358 stdoutFor: options?.stdoutFor,
359 }),
360 configLayer,
361 ),
362 ),
363 provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }),
364 );
365 return {
366 fs,
367 path,
368 home,
369 baseDir,
370 entryPath,
371 commands,
372 spawns,
373 exitCount: () => exited,
374 service,
375 };
65 const launcher = ServiceLauncherClient.ServiceLauncherClient.of({
66 managed: options.managed ?? true,
67 trial: false,
68 requestUpdate:
69 options.requestUpdate ??
70 (() =>
71 Effect.sync(() => {
72 order.push("accept");
73 return "launcher-id";
74 })),
75 prepareTrial: Effect.sync((): undefined => undefined),
37676 });
377
378 it.effect("rejects dist-tags and other non-exact versions", () =>
379 Effect.gen(function* () {
380 const context = yield* makeContext();
381 const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip);
382 assert.include(error.reason, "not an exact t3 version");
383 assert.lengthOf(context.commands, 0);
384 }),
385 );
386
387 it.effect("refuses to update a desktop-managed backend and points at the app", () =>
388 Effect.gen(function* () {
389 const context = yield* makeContext({ desktopManaged: true, bootService: true });
390 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
391 assert.include(error.reason, "desktop app");
392 assert.lengthOf(context.commands, 0);
393 assert.lengthOf(context.spawns, 0);
394 }),
395 );
396
397 it.effect("fails without touching anything when no update method applies", () =>
398 Effect.gen(function* () {
399 const context = yield* makeContext({
400 entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs",
401 });
402 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
403 assert.include(error.reason, "cannot update itself");
404 assert.lengthOf(context.commands, 0);
405 }),
406 );
407
408 it.effect("surfaces a failed npm install and never schedules a restart", () =>
409 Effect.gen(function* () {
410 const context = yield* makeContext({ failWhen: (command) => command === "npm" });
411 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
412 assert.equal(error.reason, "Could not install the requested t3 version.");
413 yield* TestClock.adjust(Duration.seconds(10));
414 assert.lengthOf(context.spawns, 0);
415 assert.equal(context.exitCount(), 0);
416 }).pipe(Effect.provide(TestClock.layer())),
77 const config = yield* ServerConfig.ServerConfig.pipe(
78 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
41779 );
418
419 it.effect("reinstalls the same version after a failed preflight", () =>
420 Effect.gen(function* () {
421 let preflightAttempts = 0;
422 const context = yield* makeContext({
423 failWhen: (command) => {
424 if (command !== NODE_PATH) return false;
425 preflightAttempts += 1;
426 return preflightAttempts === 1;
427 },
428 });
429 const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29");
430 const entryPath = context.path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
431 yield* context.fs.makeDirectory(context.path.dirname(entryPath), { recursive: true });
432 yield* context.fs.writeFileString(entryPath, "export {};\n");
433 yield* context.fs.writeFileString(
434 context.path.join(versionDir, ".install-complete"),
435 "0.0.29\n",
436 );
437
438 const firstError = yield* context.service
439 .update({ targetVersion: "0.0.29" })
440 .pipe(Effect.flip);
441 assert.include(firstError.reason, "failed its version check");
442 assert.isFalse(yield* context.fs.exists(versionDir));
443
444 const result = yield* context.service.update({ targetVersion: "0.0.29" });
445 assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" });
446 assert.deepEqual(
447 context.commands.map((entry) => entry.command),
448 [NODE_PATH, "npm", NODE_PATH],
449 );
450 }).pipe(Effect.provide(TestClock.layer())),
80 const selfUpdate = yield* ServerSelfUpdate.make().pipe(
81 Effect.provideService(ProcessRunner.ProcessRunner, runner),
82 Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher),
83 Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"),
84 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
45185 );
86 return { selfUpdate, order };
87});
45288
453 it.effect("rejects and removes an installed runtime that reports the wrong version", () =>
89it.layer(NodeServices.layer)("server self update", (it) => {
90 it.effect("stages and preflights before asking the launcher for an update ID", () =>
45491 Effect.gen(function* () {
455 const context = yield* makeContext({
456 stdoutFor: (command, args) =>
457 command === NODE_PATH && args[1] === "--version" ? "t3 v0.0.28\n" : undefined,
92 const { selfUpdate, order } = yield* makeHarness();
93 expect(yield* selfUpdate.update({ targetVersion: "1.1.0" })).toEqual({
94 targetVersion: "1.1.0",
95 method: "boot-service",
96 updateId: "launcher-id",
45897 });
459 const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29");
460
461 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
462
463 assert.include(error.reason, "did not report the requested");
464 assert.isFalse(yield* context.fs.exists(versionDir));
465 assert.lengthOf(context.spawns, 0);
98 expect(order).toEqual(["install", "preflight", "accept"]);
46699 }),
467100 );
468101
469 it.effect("reports a detached replacement spawn failure and leaves updates retryable", () =>
102 it.effect("rejects invalid versions and desktop-managed servers before staging", () =>
470103 Effect.gen(function* () {
471 const context = yield* makeContext({ failSpawn: true });
472
473 const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
474 assert.include(first.reason, "Could not start the replacement");
475
476 const second = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
477 assert.include(second.reason, "Could not start the replacement");
478 assert.notInclude(second.reason, "already in progress");
479 assert.lengthOf(context.spawns, 2);
480 assert.equal(context.exitCount(), 0);
104 const web = yield* makeHarness();
105 expect(
106 (yield* web.selfUpdate.update({ targetVersion: "latest" }).pipe(Effect.flip)).reason,
107 ).toBe("'latest' is not an exact t3 version.");
108 const desktop = yield* makeHarness({ mode: "desktop" });
109 expect(
110 (yield* desktop.selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason,
111 ).toContain("desktop app");
112 expect([...web.order, ...desktop.order]).toEqual([]);
481113 }),
482114 );
483115
484 it.effect("installs, preflights, and respawns a foreground server", () =>
116 it.effect("preserves the preflight refusal reason", () =>
485117 Effect.gen(function* () {
486 const context = yield* makeContext();
487 const progress: Array<string> = [];
488 const result = yield* context.service.update({ targetVersion: "0.0.29" }, (stage) =>
489 Effect.sync(() => progress.push(stage)),
118 const { selfUpdate } = yield* makeHarness({ preflight: "blocked" });
119 expect((yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason).toBe(
120 "local update required",
490121 );
491 assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" });
492 assert.deepEqual(progress, ["downloading", "installing"]);
493 assert.lengthOf(context.spawns, 1);
494
495 const concurrentError = yield* context.service
496 .update({ targetVersion: "0.0.30" })
497 .pipe(Effect.flip);
498 assert.include(concurrentError.reason, "already in progress");
499
500 const pinnedEntry = context.path.join(
501 context.baseDir,
502 "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs",
503 );
504 assert.deepEqual(
505 context.commands.map((entry) => [entry.command, ...entry.args].join(" ")),
506 [
507 `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`,
508 `${NODE_PATH} ${pinnedEntry} --version`,
509 ],
510 );
511
512 // The restart is deferred so the RPC acknowledgement flushes first.
513 yield* TestClock.adjust(Duration.seconds(10));
514 assert.lengthOf(context.spawns, 1);
515 const spawn = context.spawns[0];
516 assert.equal(spawn?.command, "/bin/sh");
517 assert.include(spawn?.args ?? [], pinnedEntry);
518 // The replacement replays the original CLI arguments.
519 assert.include(spawn?.args ?? [], "serve");
520 assert.equal(context.exitCount(), 1);
521 }).pipe(Effect.provide(TestClock.layer())),
522 );
523
524 it.effect("rewrites the systemd unit and restarts the boot service", () =>
525 Effect.gen(function* () {
526 const context = yield* makeContext({ bootService: true });
527 const result = yield* context.service.update({ targetVersion: "0.0.29" });
528 assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" });
529
530 const pinnedEntry = context.path.join(
531 context.baseDir,
532 "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs",
533 );
534 const unit = yield* context.fs.readFileString(
535 context.path.join(context.home, ".config", "systemd", "user", "t3code.service"),
536 );
537 assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`);
538 assert.deepEqual(
539 context.commands.map((entry) => entry.command),
540 ["npm", NODE_PATH, "systemctl"],
541 );
542 assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]);
543
544 // Restart waits until after the update acknowledgement can flush.
545 yield* TestClock.adjust(Duration.seconds(10));
546 assert.deepEqual(context.commands[3], {
547 command: "systemctl",
548 args: ["--user", "restart", "--no-block", "t3code.service"],
549 });
550 assert.lengthOf(context.spawns, 0);
551 // systemd replaces the process; the server must not exit itself.
552 assert.equal(context.exitCount(), 0);
553
554 // The queued restart returns while this process is still shutting
555 // down; the lock must stay held so a second update cannot rewrite the
556 // unit mid-teardown.
557 const concurrentError = yield* context.service
558 .update({ targetVersion: "0.0.30" })
559 .pipe(Effect.flip);
560 assert.include(concurrentError.reason, "already in progress");
561 }).pipe(Effect.provide(TestClock.layer())),
122 }),
562123 );
563124
564 it.effect("restores the previous unit and permits a retry when systemd restart fails", () =>
125 it.effect("allows only one update at a time", () =>
565126 Effect.gen(function* () {
566 let failRestart = true;
567 const context = yield* makeContext({
568 bootService: true,
569 failWhen: (command, args) => {
570 if (command !== "systemctl" || args[1] !== "restart" || !failRestart) {
571 return false;
572 }
573 failRestart = false;
574 return true;
575 },
127 const requested = yield* Deferred.make<void>();
128 const accepted = yield* Deferred.make<string>();
129 const { selfUpdate } = yield* makeHarness({
130 requestUpdate: () =>
131 Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(accepted))),
576132 });
577 const unitPath = context.path.join(
578 context.home,
579 ".config",
580 "systemd",
581 "user",
582 BOOT_SERVICE_UNIT_FILE,
583 );
584 const previousUnit = yield* context.fs.readFileString(unitPath);
585
586 const first = yield* context.service.update({ targetVersion: "0.0.29" });
587 assert.deepEqual(first, { targetVersion: "0.0.29", method: "boot-service" });
588 yield* TestClock.adjust(Duration.seconds(10));
589 yield* eventuallyFileString(unitPath, previousUnit);
590 yield* eventuallyTrue(() => context.commands.at(-1)?.args[1] === "daemon-reload");
591 assert.deepEqual(
592 context.commands.slice(-2).map((entry) => entry.args),
593 [
594 ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE],
595 ["--user", "daemon-reload"],
596 ],
597 );
598
599 const retry = yield* context.service.update({ targetVersion: "0.0.30" });
600 assert.deepEqual(retry, { targetVersion: "0.0.30", method: "boot-service" });
601 }).pipe(Effect.provide(TestClock.layer())),
602 );
603
604 it.effect("restores the previous systemd unit when daemon-reload fails", () =>
605 Effect.gen(function* () {
606 const context = yield* makeContext({
607 bootService: true,
608 failWhen: (command) => command === "systemctl",
133 const first = yield* Effect.forkChild(selfUpdate.update({ targetVersion: "1.1.0" }), {
134 startImmediately: true,
609135 });
610 const unitPath = context.path.join(
611 context.home,
612 ".config",
613 "systemd",
614 "user",
615 BOOT_SERVICE_UNIT_FILE,
616 );
617 const previousUnit = yield* context.fs.readFileString(unitPath);
618
619 const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
620 assert.include(error.reason, "Reloading systemd units failed");
621 assert.equal(yield* context.fs.readFileString(unitPath), previousUnit);
622 assert.deepEqual(
623 context.commands.map((entry) => entry.command),
624 ["npm", NODE_PATH, "systemctl", "systemctl"],
136 yield* Deferred.await(requested);
137 expect((yield* selfUpdate.update({ targetVersion: "1.1.1" }).pipe(Effect.flip)).reason).toBe(
138 "A server update is already in progress.",
625139 );
626
627 yield* TestClock.adjust(Duration.seconds(10));
628 assert.lengthOf(context.spawns, 0);
629 assert.equal(context.exitCount(), 0);
630 }).pipe(Effect.provide(TestClock.layer())),
140 yield* Deferred.succeed(accepted, "launcher-id");
141 expect((yield* Fiber.join(first)).updateId).toBe("launcher-id");
142 }),
631143 );
632144});
apps/server/src/cloud/selfUpdate.ts+113-364
......@@ -1,7 +1,3 @@
1// @effect-diagnostics nodeBuiltinImport:off
2// node:child_process directly: the foreground-server replacement must be a
3// detached fire-and-forget child that outlives this process, while Effect's
4// ChildProcessSpawner ties every child to a scope that kills it.
51import {
62 ServerSelfUpdateError,
73 type ServerSelfUpdateCapability,
......@@ -9,13 +5,7 @@ import {
95 type ServerSelfUpdateProgressStage,
106 type ServerSelfUpdateResult,
117} from "@t3tools/contracts";
12import {
13 HostProcessArguments,
14 HostProcessEnvironment,
15 HostProcessExecutablePath,
16 HostProcessPlatform,
17} from "@t3tools/shared/hostProcess";
18import * as NodeChildProcess from "node:child_process";
8import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
199import * as Context from "effect/Context";
2010import * as Duration from "effect/Duration";
2111import * as Effect from "effect/Effect";
......@@ -25,208 +15,52 @@ import * as Path from "effect/Path";
2515import * as Ref from "effect/Ref";
2616
2717import * as ServerConfig from "../config.ts";
28import { writeFileStringAtomically } from "../atomicWrite.ts";
2918import * as ProcessRunner from "../processRunner.ts";
3019import {
31 BOOT_SERVICE_UNIT_ENV,
32 BOOT_SERVICE_UNIT_FILE,
33 quoteSystemdValue,
34 renderBootServiceUnit,
35} from "./bootService.ts";
36import { ensurePinnedRuntimeInstalled, removePinnedRuntimeInstallation } from "./pinnedRuntime.ts";
37
38/**
39 * Lets a connected client replace this server with another published `t3`
40 * version over RPC — the only update path that works when the user is not at
41 * the machine (phone against a home server, relay-managed box). The target
42 * version is npm-installed into the pinned runtime and verified before
43 * anything restarts, so a failed install leaves the running server untouched.
44 */
20 ensurePinnedRuntimeInstalled,
21 PinnedRuntimeInstallError,
22 PinnedRuntimePreflightBlockedError,
23} from "./pinnedRuntime.ts";
24import { decodeServicePreflightResult } from "./servicePreflight.ts";
25import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
26import { isExactServiceVersion, SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
4527
4628const PREFLIGHT_TIMEOUT = Duration.seconds(30);
47/** Grace between acknowledging the RPC and killing the process, so the
48 response (and its relay hop) flushes before the socket drops. */
49const RESTART_DELAY = Duration.seconds(2);
50
51/** Exact npm versions only — never dist-tags — so the acknowledgement names
52 the version that was actually installed. Also keeps the value safe to
53 pass to npm and embed in filesystem paths. */
54const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
55
56export interface ServerSelfUpdateHost {
57 readonly execPath: string;
58 readonly cliEntryPath: string;
59 /** Original CLI arguments after the entry path, replayed on respawn. */
60 readonly cliArgs: ReadonlyArray<string>;
61 /** Resolves once the foreground replacement process has actually spawned. */
62 readonly spawnDetached: (
63 command: string,
64 args: ReadonlyArray<string>,
65 ) => Effect.Effect<void, ProcessRunner.ProcessSpawnError>;
66 readonly exitProcess: () => void;
67}
68
69function normalizeEntryPath(entryPath: string): string {
70 return entryPath.replaceAll("\\", "/");
71}
7229
73/**
74 * Only a published npm artifact can be swapped for another version: dev
75 * checkouts (apps/server/dist) and the desktop app's bundled backend have no
76 * npm identity, and the desktop manages its own updates.
77 */
78export function isPublishedCliEntry(entryPath: string): boolean {
79 return normalizeEntryPath(entryPath).includes("/node_modules/t3/dist/");
80}
81
82/**
83 * The update path this process can offer, or null when only a manual
84 * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this
85 * backend and owns its version; only updating the app updates it.
86 * "boot-service" — this is the systemd-supervised process from
87 * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a
88 * foreground POSIX process running a published artifact: replace it with a
89 * detached child. Windows foreground runs are unsupported for now (no
90 * equivalent of the detach-and-exec handoff below).
91 */
92export const resolveServerSelfUpdateCapability = Effect.fn(
93 "cloud.server_self_update.resolve_capability",
94)(function* (input: {
95 /** True when the desktop app supervises this backend (mode "desktop"). */
30export function resolveServerSelfUpdateCapability(input: {
9631 readonly desktopManaged: boolean;
97}) {
98 if (input.desktopManaged) {
99 return "desktop-managed" as const;
100 }
101
102 const platform = yield* HostProcessPlatform;
103 const env = yield* HostProcessEnvironment;
104 const hostArguments = yield* HostProcessArguments;
105 const fs = yield* FileSystem.FileSystem;
106 const path = yield* Path.Path;
107
108 const entryPath = hostArguments[1] ?? "";
109 if (entryPath === "") {
110 return null;
111 }
112
113 const homeDir = env.HOME ?? "";
114 if (platform === "linux" && homeDir !== "") {
115 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
116 const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe(
117 Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))),
118 Effect.orElseSucceed(() => false),
119 );
120 // INVOCATION_ID only proves that some systemd unit launched us. The
121 // explicit marker written into t3code.service identifies this unit as the
122 // supervisor that will replace the current process when restarted.
123 if (
124 unitReferencesEntry &&
125 (env.INVOCATION_ID ?? "") !== "" &&
126 env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_UNIT_FILE
127 ) {
128 return "boot-service" as const;
129 }
130
131 // A process owned by another (or a legacy unmarked) systemd unit must not
132 // use the foreground respawn path: Restart=always could otherwise launch
133 // the old unit beside the detached replacement.
134 if ((env.INVOCATION_ID ?? "") !== "") {
135 return null;
136 }
137 }
138
139 if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) {
140 return "respawn" as const;
141 }
142
143 return null;
144});
32 readonly launcherManaged: boolean;
33}): ServerSelfUpdateCapability | null {
34 if (input.desktopManaged) return "desktop-managed" as const;
35 return input.launcherManaged ? ("boot-service" as const) : null;
36}
14537
14638export class ServerSelfUpdate extends Context.Service<
14739 ServerSelfUpdate,
14840 {
14941 readonly update: (
15042 input: ServerSelfUpdateInput,
151 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void, never, never>,
43 reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect<void>,
15244 ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
15345 }
15446>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
15547
156export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: {
157 readonly host?: Partial<ServerSelfUpdateHost>;
158}) {
48export const make = Effect.fn("cloud.server_self_update.make")(function* () {
15949 const serverConfig = yield* ServerConfig.ServerConfig;
50 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
51 const runner = yield* ProcessRunner.ProcessRunner;
16052 const fs = yield* FileSystem.FileSystem;
16153 const path = yield* Path.Path;
162 const runner = yield* ProcessRunner.ProcessRunner;
163 const env = yield* HostProcessEnvironment;
164 const hostExecPath = yield* HostProcessExecutablePath;
165 const hostArguments = yield* HostProcessArguments;
166 const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({
167 desktopManaged: serverConfig.mode === "desktop",
168 });
169
170 const host: ServerSelfUpdateHost = {
171 execPath: options?.host?.execPath ?? hostExecPath,
172 cliEntryPath: options?.host?.cliEntryPath ?? hostArguments[1] ?? "",
173 cliArgs: options?.host?.cliArgs ?? hostArguments.slice(2),
174 spawnDetached:
175 options?.host?.spawnDetached ??
176 ((command, args) =>
177 Effect.callback<void, ProcessRunner.ProcessSpawnError>((resume) => {
178 const spawnError = (cause: unknown) =>
179 new ProcessRunner.ProcessSpawnError({
180 command,
181 argumentCount: args.length,
182 cause,
183 });
184 let child: NodeChildProcess.ChildProcess;
185 try {
186 child = NodeChildProcess.spawn(command, [...args], {
187 detached: true,
188 stdio: "ignore",
189 });
190 } catch (cause) {
191 resume(Effect.fail(spawnError(cause)));
192 return;
193 }
194
195 const onSpawnError = (cause: Error) => resume(Effect.fail(spawnError(cause)));
196 child.once("error", onSpawnError);
197 child.once("spawn", () => {
198 child.removeListener("error", onSpawnError);
199 // Keep asynchronous child errors from becoming uncaught after the
200 // successful spawn handoff has already been acknowledged.
201 child.on("error", () => undefined);
202 child.unref();
203 resume(Effect.void);
204 });
205 })),
206 exitProcess: options?.host?.exitProcess ?? (() => process.exit(0)),
207 };
208
54 const execPath = yield* HostProcessExecutablePath;
20955 const inFlight = yield* Ref.make(false);
21056
57 const capability: ServerSelfUpdateCapability | null =
58 serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null;
21159 const failWith = (reason: string, cause?: unknown) =>
21260 cause === undefined
21361 ? new ServerSelfUpdateError({ reason })
21462 : new ServerSelfUpdateError({ reason, cause });
21563
216 /** Deferred so the RPC acknowledgement flushes before the process dies.
217 Detached from the request scope: the triggering connection is exactly
218 what the restart tears down. */
219 const scheduleRestart = (restart: Effect.Effect<void>) =>
220 Effect.sleep(RESTART_DELAY).pipe(
221 Effect.andThen(restart),
222 Effect.forkDetach({ startImmediately: true }),
223 );
224 const writeUnitAtomically = (filePath: string, contents: string) =>
225 writeFileStringAtomically({ filePath, contents }).pipe(
226 Effect.provideService(FileSystem.FileSystem, fs),
227 Effect.provideService(Path.Path, path),
228 );
229
23064 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
23165 "cloud.server_self_update.update",
23266 )(function* (input, reportProgress = () => Effect.void) {
......@@ -237,208 +71,123 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
23771 }
23872 if (capability === null) {
23973 return yield* failWith(
240 "This server cannot update itself; relaunch it manually with the new version.",
74 "Remote updates require the T3 Code background service. Run `t3 service install` on the server machine.",
24175 );
24276 }
243 const activeMethod = capability;
77
24478 const targetVersion = input.targetVersion.trim();
245 if (!EXACT_VERSION_PATTERN.test(targetVersion)) {
79 if (!isExactServiceVersion(targetVersion)) {
24680 return yield* failWith(`'${targetVersion}' is not an exact t3 version.`);
24781 }
248
249 const alreadyRunning = yield* Ref.getAndSet(inFlight, true);
250 if (alreadyRunning) {
82 if (yield* Ref.getAndSet(inFlight, true)) {
25183 return yield* failWith("A server update is already in progress.");
25284 }
25385
25486 return yield* Effect.gen(function* () {
25587 yield* reportProgress("downloading");
256 const runtimePaths = yield* ensurePinnedRuntimeInstalled({
88 const paths = yield* ensurePinnedRuntimeInstalled({
25789 baseDir: serverConfig.baseDir,
25890 version: targetVersion,
25991 fs,
26092 path,
26193 runner,
94 validate: (runtime) =>
95 runner
96 .run({
97 command: execPath,
98 args: [
99 runtime.entryPath,
100 "__service-preflight",
101 "--database-path",
102 serverConfig.dbPath,
103 "--launcher-protocol",
104 String(SERVICE_LAUNCHER_PROTOCOL),
105 ],
106 timeout: PREFLIGHT_TIMEOUT,
107 })
108 .pipe(
109 Effect.mapError(
110 (cause) =>
111 new PinnedRuntimeInstallError({
112 step: "running the staged service preflight",
113 cause,
114 }),
115 ),
116 Effect.flatMap(
117 (
118 result,
119 ): Effect.Effect<
120 void,
121 PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError
122 > => {
123 if (result.code !== 0) {
124 return Effect.fail(
125 new PinnedRuntimeInstallError({
126 step: "running the staged service preflight",
127 exitCode: Number(result.code),
128 stdoutLength: result.stdout.length,
129 stderrLength: result.stderr.length,
130 }),
131 );
132 }
133 let parsed: unknown;
134 try {
135 parsed = JSON.parse(result.stdout.trim());
136 } catch (cause) {
137 return Effect.fail(
138 new PinnedRuntimeInstallError({
139 step: "decoding the staged service preflight",
140 cause,
141 }),
142 );
143 }
144 const preflight = decodeServicePreflightResult(parsed);
145 if (preflight === undefined || preflight.version !== targetVersion) {
146 return Effect.fail(
147 new PinnedRuntimeInstallError({
148 step: "verifying the staged service preflight",
149 }),
150 );
151 }
152 return preflight.status === "ready"
153 ? Effect.void
154 : Effect.fail(
155 new PinnedRuntimePreflightBlockedError({
156 version: targetVersion,
157 reason: preflight.reason,
158 }),
159 );
160 },
161 ),
162 ),
262163 }).pipe(
263 Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)),
164 Effect.mapError((error) =>
165 error._tag === "PinnedRuntimePreflightBlockedError"
166 ? failWith(error.reason, error)
167 : failWith(`Could not prepare t3@${targetVersion}.`, error),
168 ),
264169 );
265170
266171 yield* reportProgress("installing");
267 // A broken artifact (failed native build, incompatible node) must be
268 // caught while the current server is still alive to report it.
269 const preflight = yield* runner
270 .run({
271 command: host.execPath,
272 args: [runtimePaths.entryPath, "--version"],
273 timeout: PREFLIGHT_TIMEOUT,
274 })
172 const updateId = yield* launcher
173 .requestUpdate({ targetVersion })
275174 .pipe(
276 Effect.mapError((cause) =>
277 failWith(`Could not verify the installed t3@${targetVersion}.`, cause),
278 ),
279 );
280 // Effect CLI's unstable formatVersion currently emits `${name} v${version}`.
281 // Extract the version token so surrounding presentation changes do not break updates.
282 const reportedVersion = /\bv(\S+)\s*$/.exec(preflight.stdout)?.[1];
283 if (preflight.code !== 0 || reportedVersion !== targetVersion) {
284 // A completed npm install can still be unusable under this Node or on
285 // this machine. Remove its sentinel and tree so a retry of the same
286 // version performs a clean install instead of reusing a known-bad one.
287 yield* removePinnedRuntimeInstallation({
288 baseDir: serverConfig.baseDir,
289 version: targetVersion,
290 fs,
291 path,
292 }).pipe(
293175 Effect.mapError((error) =>
294 failWith(`Could not remove the failed t3@${targetVersion} installation.`, error),
295 ),
296 );
297 return yield* failWith(
298 preflight.code !== 0
299 ? `The installed t3@${targetVersion} failed its version check (exit code ${String(preflight.code)}).`
300 : `The installed runtime did not report the requested t3@${targetVersion} version.`,
301 );
302 }
303
304 if (activeMethod === "boot-service") {
305 const homeDir = env.HOME ?? "";
306 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
307 const previousUnit = yield* fs
308 .readFileString(unitPath)
309 .pipe(
310 Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)),
311 );
312 // Same shape bootService.install writes, so host lifecycle commands
313 // still recognize the unit as current.
314 const unit = renderBootServiceUnit({
315 nodePath: host.execPath,
316 t3EntryPath: runtimePaths.entryPath,
317 baseDir: serverConfig.baseDir,
318 logPath: path.join(serverConfig.logsDir, "boot-service.log"),
319 unitPath,
320 });
321 yield* writeUnitAtomically(unitPath, unit).pipe(
322 Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)),
323 );
324
325 const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () {
326 const reload = yield* runner
327 .run({ command: "systemctl", args: ["--user", "daemon-reload"] })
328 .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause)));
329 if (reload.code !== 0) {
330 return yield* failWith(
331 `Reloading systemd units failed (exit code ${String(reload.code)}).`,
332 );
333 }
334 });
335
336 yield* reloadSystemd().pipe(
337 Effect.catch((reloadError) =>
338 writeUnitAtomically(unitPath, previousUnit).pipe(
339 Effect.mapError((rollbackCause) =>
340 failWith("Could not restore the previous systemd unit.", {
341 reloadError,
342 rollbackCause,
343 }),
344 ),
345 // Systemd should still have the old unit in memory after the
346 // failed reload, but retry after restoring in case it applied a
347 // partial update before returning an error.
348 Effect.andThen(reloadSystemd().pipe(Effect.ignore)),
349 Effect.andThen(Effect.fail(reloadError)),
350 ),
351 ),
352 );
353 yield* Effect.logInfo("Server self-update installed; restarting boot service.", {
354 targetVersion,
355 });
356 // Restart after the acknowledgement has had time to cross any relay
357 // hop. --no-block queues the restart job and exits before systemd
358 // stops this unit: a blocking restart's SIGTERM reaches the systemctl
359 // child (it shares this service's cgroup), which read as a restart
360 // failure and rolled the new unit back while the old server finished
361 // shutting down. With the handoff race gone, a non-zero exit or spawn
362 // error means systemd genuinely rejected the job while this process is
363 // still alive, so restoring the previous unit below stays correct.
364 yield* scheduleRestart(
365 Effect.gen(function* () {
366 const restart = yield* runner
367 .run({
368 command: "systemctl",
369 args: ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE],
370 })
371 .pipe(
372 Effect.mapError((cause) =>
373 failWith("Could not restart the systemd boot service.", cause),
374 ),
375 );
376 if (restart.code !== 0) {
377 return yield* failWith(
378 `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`,
379 );
380 }
381 }).pipe(
382 Effect.catch((restartError) =>
383 writeUnitAtomically(unitPath, previousUnit).pipe(
384 Effect.andThen(reloadSystemd()),
385 Effect.mapError((rollbackError) =>
386 failWith("Could not restore the previous systemd unit.", {
387 restartError,
388 rollbackError,
389 }),
390 ),
391 Effect.andThen(Effect.fail(restartError)),
392 ),
393 ),
394 Effect.catch((error) =>
395 Effect.logError("Server self-update could not restart the boot service.").pipe(
396 Effect.annotateLogs({ targetVersion, error: error.reason }),
397 // Permit a retry only after the failed handoff was rolled
398 // back. A queued restart returns while this process is still
399 // shutting down; releasing the lock then would let a second
400 // update rewrite the unit mid-teardown.
401 Effect.andThen(Ref.set(inFlight, false)),
402 ),
403 ),
404 ),
405 );
406 } else {
407 // Spawn the shim before acknowledging the RPC so ENOENT/EACCES and
408 // other launch failures leave this server alive and return a useful
409 // error. The shim itself waits until after the acknowledgement and
410 // deferred exit before binding the replacement server.
411 yield* host
412 .spawnDetached("/bin/sh", [
413 "-c",
414 'sleep 3; exec "$@"',
415 "t3-self-update",
416 host.execPath,
417 runtimePaths.entryPath,
418 ...host.cliArgs,
419 ])
420 .pipe(
421 Effect.mapError((cause) =>
422 failWith("Could not start the replacement t3 process.", cause),
423 ),
424 );
425 yield* Effect.logInfo("Server self-update installed; respawning.", { targetVersion });
426 yield* scheduleRestart(
427 Effect.try({
428 try: () => host.exitProcess(),
429 catch: (cause) => failWith("Could not exit the replaced t3 process.", cause),
430 }).pipe(
431 Effect.catch((error) =>
432 Effect.logError("Server self-update could not exit the replaced process.").pipe(
433 Effect.annotateLogs({ targetVersion, error: error.reason }),
434 Effect.ensuring(Ref.set(inFlight, false)),
435 ),
176 failWith(
177 error._tag === "ServiceLauncherRejectedError"
178 ? error.reason
179 : "Could not ask the service launcher to activate the prepared update.",
180 error,
436181 ),
437182 ),
438183 );
439 }
440184
441 return { targetVersion, method: activeMethod };
185 yield* Effect.logInfo("Server update prepared; handing off to the service launcher.", {
186 updateId,
187 targetVersion,
188 runtimePath: paths.entryPath,
189 });
190 return { targetVersion, method: "boot-service" as const, updateId };
442191 }).pipe(Effect.onError(() => Ref.set(inFlight, false)));
443192 });
444193
apps/server/src/cloud/serviceLauncherClient.test.ts created+130
......@@ -0,0 +1,130 @@
1import { expect, it } from "@effect/vitest";
2import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5
6import {
7 SERVICE_LAUNCHER_CONTEXT_ENV,
8 type ServiceLauncherChildMessage,
9 type ServiceLauncherParentMessage,
10} from "./serviceProtocol.ts";
11import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
12
13class FakeLauncherProcess {
14 readonly connected = true;
15 readonly env: Record<string, string | undefined>;
16 readonly sent: ServiceLauncherChildMessage[] = [];
17 readonly #listeners = new Map<string, Set<(...args: ReadonlyArray<unknown>) => void>>();
18
19 constructor(context: unknown) {
20 this.env = { [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) };
21 }
22
23 send = (message: ServiceLauncherChildMessage, callback?: (error: Error | null) => void) => {
24 this.sent.push(message);
25 callback?.(null);
26 return true;
27 };
28
29 on = (event: "message" | "disconnect", listener: (...args: ReadonlyArray<unknown>) => void) => {
30 const listeners = this.#listeners.get(event) ?? new Set();
31 listeners.add(listener);
32 this.#listeners.set(event, listeners);
33 };
34
35 off = (event: "message" | "disconnect", listener: (...args: ReadonlyArray<unknown>) => void) => {
36 this.#listeners.get(event)?.delete(listener);
37 };
38
39 emit(message: ServiceLauncherParentMessage) {
40 for (const listener of this.#listeners.get("message") ?? []) listener(message);
41 }
42}
43
44const makeClient = (host: FakeLauncherProcess, currentVersion: string) =>
45 ServiceLauncherClient.make({ currentVersion }).pipe(
46 Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, host),
47 Effect.provideService(HostProcessEnvironment, host.env),
48 );
49
50it.effect("waits for the launcher to durably commit the trial update ID", () =>
51 Effect.gen(function* () {
52 const pending = {
53 id: "update-1",
54 fromVersion: "1.0.0",
55 targetVersion: "1.1.0",
56 status: "pending" as const,
57 };
58 const host = new FakeLauncherProcess({
59 protocol: 1,
60 childVersion: "1.1.0",
61 update: pending,
62 });
63 const client = yield* makeClient(host, "1.1.0");
64 const prepared = yield* Effect.forkChild(client.prepareTrial, { startImmediately: true });
65 yield* Effect.yieldNow;
66 expect(host.sent).toEqual([{ type: "prepared", updateId: "update-1" }]);
67
68 const committed = {
69 id: pending.id,
70 fromVersion: pending.fromVersion,
71 targetVersion: pending.targetVersion,
72 status: "committed" as const,
73 };
74 host.emit({ type: "committed", updateId: committed.id });
75 expect(yield* Fiber.join(prepared)).toEqual(committed);
76 }),
77);
78
79it.effect("returns the launcher-generated ID only after update acceptance", () =>
80 Effect.gen(function* () {
81 const host = new FakeLauncherProcess({
82 protocol: 1,
83 childVersion: "1.0.0",
84 });
85 const client = yield* makeClient(host, "1.0.0");
86 const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), {
87 startImmediately: true,
88 });
89 yield* Effect.yieldNow;
90 host.emit({
91 type: "update-accepted",
92 updateId: "launcher-id",
93 });
94 expect(yield* Fiber.join(requested)).toBe("launcher-id");
95 }),
96);
97
98it.effect("preserves a launcher rejection as a distinct error", () =>
99 Effect.gen(function* () {
100 const host = new FakeLauncherProcess({ protocol: 1, childVersion: "1.0.0" });
101 const client = yield* makeClient(host, "1.0.0");
102 const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), {
103 startImmediately: true,
104 });
105 yield* Effect.yieldNow;
106 host.emit({ type: "update-rejected", reason: "requires local update" });
107 expect(yield* Fiber.join(requested).pipe(Effect.flip)).toMatchObject({
108 _tag: "ServiceLauncherRejectedError",
109 targetVersion: "1.1.0",
110 reason: "requires local update",
111 });
112 }),
113);
114
115it.effect("rejects contradictory trial context instead of leaving activation closed", () =>
116 Effect.gen(function* () {
117 const host = new FakeLauncherProcess({
118 protocol: 1,
119 childVersion: "1.1.0",
120 update: {
121 id: "update-1",
122 fromVersion: "1.0.0",
123 targetVersion: "1.2.0",
124 status: "pending",
125 },
126 });
127 const error = yield* makeClient(host, "1.1.0").pipe(Effect.flip);
128 expect(error.message).toBe("The service launcher supplied invalid startup context.");
129 }),
130);
apps/server/src/cloud/serviceLauncherClient.ts created+249
......@@ -0,0 +1,249 @@
1import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";
2import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7
8import packageJson from "../../package.json" with { type: "json" };
9import {
10 decodeServiceLauncherContext,
11 decodeServiceLauncherParentMessage,
12 SERVICE_LAUNCHER_CONTEXT_ENV,
13 type ServiceLauncherChildMessage,
14 type ServiceLauncherParentMessage,
15} from "./serviceProtocol.ts";
16
17export class ServiceLauncherClientError extends Schema.TaggedErrorClass<ServiceLauncherClientError>()(
18 "ServiceLauncherClientError",
19 {
20 operation: Schema.Literals([
21 "decode-context",
22 "version-mismatch",
23 "ipc-unavailable",
24 "unmanaged",
25 "send",
26 "disconnect",
27 "timeout",
28 ]),
29 cause: Schema.optional(Schema.Defect()),
30 },
31) {
32 override get message(): string {
33 switch (this.operation) {
34 case "decode-context":
35 return "The service launcher supplied invalid startup context.";
36 case "version-mismatch":
37 return "The service launcher started a different t3 version.";
38 case "ipc-unavailable":
39 return "The service launcher IPC channel is unavailable.";
40 case "unmanaged":
41 return "This server is not managed by the launcher.";
42 case "send":
43 return "Could not send a request to the service launcher.";
44 case "disconnect":
45 return "The service launcher disconnected before acknowledging the request.";
46 case "timeout":
47 return "The service launcher did not respond within 30 seconds.";
48 }
49 }
50}
51
52export class ServiceLauncherRejectedError extends Schema.TaggedErrorClass<ServiceLauncherRejectedError>()(
53 "ServiceLauncherRejectedError",
54 {
55 targetVersion: Schema.String,
56 reason: Schema.String,
57 },
58) {
59 override get message(): string {
60 return this.reason;
61 }
62}
63
64interface ServiceLauncherProcess {
65 readonly connected: boolean;
66 readonly send: (
67 message: ServiceLauncherChildMessage,
68 callback?: (error: Error | null) => void,
69 ) => boolean;
70 readonly on: (
71 event: "message" | "disconnect",
72 listener: (...args: ReadonlyArray<unknown>) => void,
73 ) => void;
74 readonly off: (
75 event: "message" | "disconnect",
76 listener: (...args: ReadonlyArray<unknown>) => void,
77 ) => void;
78}
79
80export const ServiceLauncherHostProcess = Context.Reference<ServiceLauncherProcess>(
81 "t3/cloud/serviceLauncherHostProcess",
82 {
83 defaultValue: () => ({
84 connected: process.connected && process.send !== undefined,
85 send: (message, callback) => {
86 if (process.send === undefined) return false;
87 return callback === undefined ? process.send(message) : process.send(message, callback);
88 },
89 on: (event, listener) => {
90 process.on(event, listener);
91 },
92 off: (event, listener) => {
93 process.off(event, listener);
94 },
95 }),
96 },
97);
98
99export class ServiceLauncherClient extends Context.Service<
100 ServiceLauncherClient,
101 {
102 readonly managed: boolean;
103 readonly trial: boolean;
104 readonly requestUpdate: (input: {
105 readonly targetVersion: string;
106 }) => Effect.Effect<string, ServiceLauncherClientError | ServiceLauncherRejectedError>;
107 readonly prepareTrial: Effect.Effect<
108 ServerSelfUpdateOutcome | undefined,
109 ServiceLauncherClientError
110 >;
111 }
112>()("t3/cloud/serviceLauncherClient") {}
113
114const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup")(
115 function* (options?: { readonly currentVersion?: string }) {
116 const host = yield* ServiceLauncherHostProcess;
117 const environment = yield* HostProcessEnvironment;
118 const currentVersion = options?.currentVersion ?? packageJson.version;
119 const rawContext = environment[SERVICE_LAUNCHER_CONTEXT_ENV];
120 const context = rawContext === undefined ? undefined : decodeServiceLauncherContext(rawContext);
121
122 if (rawContext !== undefined && context === undefined) {
123 return yield* new ServiceLauncherClientError({ operation: "decode-context" });
124 }
125 if (context !== undefined && context.childVersion !== currentVersion) {
126 return yield* new ServiceLauncherClientError({ operation: "version-mismatch" });
127 }
128
129 const managed = context !== undefined && host.connected;
130 if (context !== undefined && !managed) {
131 return yield* new ServiceLauncherClientError({ operation: "ipc-unavailable" });
132 }
133
134 return { host, context, managed };
135 },
136);
137
138export const resolveServiceLauncherMode = Effect.fn("cloud.service_launcher_client.resolve_mode")(
139 function* () {
140 const { context, managed } = yield* resolveStartup();
141 return { managed, trial: context?.update?.status === "pending" };
142 },
143);
144
145export const make = Effect.fn("cloud.service_launcher_client.make")(function* (options?: {
146 readonly currentVersion?: string;
147}) {
148 const { host, context, managed } = yield* resolveStartup(options);
149
150 const exchange = (
151 message: ServiceLauncherChildMessage,
152 accept: (reply: ServiceLauncherParentMessage) => boolean,
153 ) =>
154 Effect.callback<ServiceLauncherParentMessage, ServiceLauncherClientError>((resume) => {
155 if (!managed) {
156 resume(Effect.fail(new ServiceLauncherClientError({ operation: "unmanaged" })));
157 return;
158 }
159
160 let settled = false;
161 const cleanup = () => {
162 host.off("message", onMessage);
163 host.off("disconnect", onDisconnect);
164 };
165 const settle = (
166 effect: Effect.Effect<ServiceLauncherParentMessage, ServiceLauncherClientError>,
167 ) => {
168 if (settled) return;
169 settled = true;
170 cleanup();
171 resume(effect);
172 };
173 const onMessage = (...args: ReadonlyArray<unknown>) => {
174 const reply = decodeServiceLauncherParentMessage(args[0]);
175 if (reply !== undefined && accept(reply)) settle(Effect.succeed(reply));
176 };
177 const onDisconnect = () =>
178 settle(Effect.fail(new ServiceLauncherClientError({ operation: "disconnect" })));
179
180 host.on("message", onMessage);
181 host.on("disconnect", onDisconnect);
182 try {
183 host.send(message, (error) => {
184 if (error !== null) {
185 settle(
186 Effect.fail(new ServiceLauncherClientError({ operation: "send", cause: error })),
187 );
188 }
189 });
190 } catch (cause) {
191 settle(Effect.fail(new ServiceLauncherClientError({ operation: "send", cause })));
192 }
193
194 return Effect.sync(cleanup);
195 }).pipe(
196 Effect.timeoutOrElse({
197 duration: "30 seconds",
198 orElse: () => Effect.fail(new ServiceLauncherClientError({ operation: "timeout" })),
199 }),
200 );
201
202 const requestUpdate = (input: { readonly targetVersion: string }) =>
203 exchange(
204 { type: "request-update", ...input },
205 (reply) => reply.type === "update-accepted" || reply.type === "update-rejected",
206 ).pipe(
207 Effect.flatMap((reply) =>
208 reply.type === "update-accepted"
209 ? Effect.succeed(reply.updateId)
210 : reply.type === "update-rejected"
211 ? Effect.fail(
212 new ServiceLauncherRejectedError({
213 targetVersion: input.targetVersion,
214 reason: reply.reason,
215 }),
216 )
217 : Effect.die("service launcher returned an impossible update response"),
218 ),
219 );
220
221 const pending = context?.update?.status === "pending" ? context.update : undefined;
222 const outcome =
223 context?.update === undefined || context.update.status === "pending"
224 ? undefined
225 : context.update;
226 const prepareTrial =
227 pending !== undefined
228 ? exchange(
229 { type: "prepared", updateId: pending.id },
230 (reply) => reply.type === "committed" && reply.updateId === pending.id,
231 ).pipe(
232 Effect.flatMap((reply) => {
233 if (reply.type !== "committed") {
234 return Effect.die("service launcher returned an impossible prepared response");
235 }
236 return Effect.succeed({ ...pending, status: "committed" as const });
237 }),
238 )
239 : Effect.succeed(outcome);
240
241 return ServiceLauncherClient.of({
242 managed,
243 trial: pending !== undefined,
244 requestUpdate,
245 prepareTrial,
246 });
247});
248
249export const layer = Layer.effect(ServiceLauncherClient, make());
apps/server/src/cloud/servicePreflight.test.ts created+47
......@@ -0,0 +1,47 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as NodeSqlite from "node:sqlite";
8
9import { migrationManifest } from "../persistence/Migrations.ts";
10import { runServicePreflight } from "./servicePreflight.ts";
11
12it.layer(NodeServices.layer)("service update preflight", (it) => {
13 it.effect("requires exact migration-manifest equality without mutating the database", () =>
14 Effect.gen(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const path = yield* Path.Path;
17 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-preflight-test-" });
18 const databasePath = path.join(root, "state.sqlite");
19 const database = new NodeSqlite.DatabaseSync(databasePath);
20 database.exec("CREATE TABLE effect_sql_migrations (migration_id INTEGER, name TEXT)");
21 const insert = database.prepare(
22 "INSERT INTO effect_sql_migrations (migration_id, name) VALUES (?, ?)",
23 );
24 for (const [id, name] of migrationManifest) insert.run(id, name);
25 database.close();
26
27 expect(runServicePreflight({ databasePath, launcherProtocol: 1, version: "1.2.3" })).toEqual({
28 status: "ready",
29 version: "1.2.3",
30 launcherProtocol: 1,
31 });
32
33 const changed = new NodeSqlite.DatabaseSync(databasePath);
34 changed.exec("DELETE FROM effect_sql_migrations WHERE migration_id = 35");
35 changed.close();
36 const blocked = runServicePreflight({
37 databasePath,
38 launcherProtocol: 1,
39 version: "1.2.3",
40 });
41 expect(blocked.status).toBe("blocked");
42 if (blocked.status === "blocked") {
43 expect(blocked.reason).toContain("npx t3@1.2.3 service update");
44 }
45 }),
46 );
47});
apps/server/src/cloud/servicePreflight.ts created+96
......@@ -0,0 +1,96 @@
1import * as NodeSqlite from "node:sqlite";
2
3import packageJson from "../../package.json" with { type: "json" };
4import { migrationManifest } from "../persistence/Migrations.ts";
5import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
6
7export type ServicePreflightResult =
8 | {
9 readonly status: "ready";
10 readonly version: string;
11 readonly launcherProtocol: typeof SERVICE_LAUNCHER_PROTOCOL;
12 }
13 | {
14 readonly status: "blocked";
15 readonly version: string;
16 readonly reason: string;
17 };
18
19const localUpdateReason = (version: string) =>
20 `This version includes a database update and cannot be installed remotely. Run \`npx t3@${version} service update\` on the server machine.`;
21
22const isMigrationRow = (
23 value: unknown,
24): value is { readonly migration_id: number; readonly name: string } =>
25 typeof value === "object" &&
26 value !== null &&
27 "migration_id" in value &&
28 typeof value.migration_id === "number" &&
29 "name" in value &&
30 typeof value.name === "string";
31
32export function runServicePreflight(input: {
33 readonly databasePath: string;
34 readonly launcherProtocol: number;
35 readonly version?: string;
36}): ServicePreflightResult {
37 const version = input.version ?? packageJson.version;
38 if (input.launcherProtocol !== SERVICE_LAUNCHER_PROTOCOL) {
39 return {
40 status: "blocked",
41 version,
42 reason:
43 "This release requires a newer T3 Code service launcher. Update it on the server machine.",
44 };
45 }
46
47 try {
48 const database = new NodeSqlite.DatabaseSync(input.databasePath, { readOnly: true });
49 try {
50 const rows: ReadonlyArray<unknown> = database
51 .prepare("SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id")
52 .all();
53 const exact =
54 rows.length === migrationManifest.length &&
55 rows.every((row, index) => {
56 const expected = migrationManifest[index];
57 return (
58 isMigrationRow(row) && row.migration_id === expected?.[0] && row.name === expected?.[1]
59 );
60 });
61 if (!exact) return { status: "blocked", version, reason: localUpdateReason(version) };
62 } finally {
63 database.close();
64 }
65 } catch {
66 return { status: "blocked", version, reason: localUpdateReason(version) };
67 }
68
69 return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL };
70}
71
72export function decodeServicePreflightResult(value: unknown): ServicePreflightResult | undefined {
73 if (typeof value !== "object" || value === null) {
74 return undefined;
75 }
76 const record = value as Record<string, unknown>;
77 if (
78 record.status === "ready" &&
79 record.launcherProtocol === SERVICE_LAUNCHER_PROTOCOL &&
80 typeof record.version === "string"
81 ) {
82 return {
83 status: "ready",
84 version: record.version,
85 launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
86 };
87 }
88 if (
89 record.status === "blocked" &&
90 typeof record.version === "string" &&
91 typeof record.reason === "string"
92 ) {
93 return { status: "blocked", version: record.version, reason: record.reason };
94 }
95 return undefined;
96}
apps/server/src/cloud/serviceProtocol.ts created+226
......@@ -0,0 +1,226 @@
1import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";
2
3export const SERVICE_LAUNCHER_PROTOCOL = 1 as const;
4export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT";
5export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs";
6export const SERVICE_STATE_FILE = "service-state.json";
7
8export interface PendingServiceUpdate {
9 readonly id: string;
10 readonly fromVersion: string;
11 readonly targetVersion: string;
12 readonly status: "pending";
13}
14
15export type ServiceUpdateRecord = PendingServiceUpdate | ServerSelfUpdateOutcome;
16
17export interface ServiceState {
18 readonly protocol: typeof SERVICE_LAUNCHER_PROTOCOL;
19 readonly activeVersion: string;
20 readonly update?: ServiceUpdateRecord;
21}
22
23/** Context is copied from launcher-owned state when a child is spawned. */
24export interface ServiceLauncherContext {
25 readonly protocol: typeof SERVICE_LAUNCHER_PROTOCOL;
26 readonly childVersion: string;
27 readonly update?: ServiceUpdateRecord;
28}
29
30export type ServiceLauncherChildMessage =
31 | {
32 readonly type: "request-update";
33 readonly targetVersion: string;
34 }
35 | {
36 readonly type: "prepared";
37 readonly updateId: string;
38 };
39
40export type ServiceLauncherParentMessage =
41 | {
42 readonly type: "update-accepted";
43 readonly updateId: string;
44 }
45 | {
46 readonly type: "update-rejected";
47 readonly reason: string;
48 }
49 | {
50 readonly type: "committed";
51 readonly updateId: string;
52 };
53
54const SEMVER_NUMBER = "(?:0|[1-9]\\d*)";
55const SEMVER_PRERELEASE = `(?:${SEMVER_NUMBER}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)`;
56const EXACT_SERVICE_VERSION = new RegExp(
57 `^${SEMVER_NUMBER}\\.${SEMVER_NUMBER}\\.${SEMVER_NUMBER}(?:-${SEMVER_PRERELEASE}(?:\\.${SEMVER_PRERELEASE})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`,
58);
59
60/** Accepts exact SemVer only: never dist-tags or ranges passed to npm or filesystem paths. */
61export const isExactServiceVersion = (version: string): boolean =>
62 EXACT_SERVICE_VERSION.test(version);
63
64const isRecord = (value: unknown): value is Record<string, unknown> =>
65 typeof value === "object" && value !== null && !Array.isArray(value);
66
67export function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undefined {
68 if (!isRecord(value)) return undefined;
69 const { id, fromVersion, targetVersion, status } = value;
70 if (
71 typeof id !== "string" ||
72 id.trim() === "" ||
73 typeof fromVersion !== "string" ||
74 !isExactServiceVersion(fromVersion) ||
75 typeof targetVersion !== "string" ||
76 !isExactServiceVersion(targetVersion)
77 ) {
78 return undefined;
79 }
80 if (status === "pending") {
81 return { id, fromVersion, targetVersion, status };
82 }
83 if (
84 (status === "committed" || status === "rolled-back" || status === "failed") &&
85 (value.reason === undefined || (typeof value.reason === "string" && value.reason.trim() !== ""))
86 ) {
87 return {
88 id,
89 fromVersion,
90 targetVersion,
91 status,
92 ...(typeof value.reason === "string" ? { reason: value.reason } : {}),
93 };
94 }
95 return undefined;
96}
97
98/** SemVer precedence for exact versions. Build metadata is ignored. */
99export function compareExactServiceVersions(left: string, right: string): number {
100 const parse = (version: string) => {
101 const withoutBuild = version.split("+", 1)[0] ?? version;
102 const separator = withoutBuild.indexOf("-");
103 const core = separator === -1 ? withoutBuild : withoutBuild.slice(0, separator);
104 const prerelease = separator === -1 ? undefined : withoutBuild.slice(separator + 1);
105 const [major = "0", minor = "0", patch = "0"] = core.split(".");
106 return {
107 core: [BigInt(major), BigInt(minor), BigInt(patch)] as const,
108 prerelease: prerelease?.split(".") ?? [],
109 };
110 };
111 const a = parse(left);
112 const b = parse(right);
113 for (let index = 0; index < 3; index += 1) {
114 const x = a.core[index] ?? 0n;
115 const y = b.core[index] ?? 0n;
116 if (x !== y) return x < y ? -1 : 1;
117 }
118 if (a.prerelease.length === 0 || b.prerelease.length === 0) {
119 return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
120 }
121 const count = Math.max(a.prerelease.length, b.prerelease.length);
122 for (let index = 0; index < count; index += 1) {
123 const x = a.prerelease[index];
124 const y = b.prerelease[index];
125 if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
126 if (x === y) continue;
127 const xNumeric = /^\d+$/.test(x);
128 const yNumeric = /^\d+$/.test(y);
129 if (xNumeric && yNumeric) return BigInt(x) < BigInt(y) ? -1 : 1;
130 if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
131 return x < y ? -1 : 1;
132 }
133 return 0;
134}
135
136export function decodeServiceState(value: unknown): ServiceState | undefined {
137 if (!isRecord(value)) return undefined;
138 const update = value.update === undefined ? undefined : decodeServiceUpdate(value.update);
139 if (
140 value.protocol !== SERVICE_LAUNCHER_PROTOCOL ||
141 typeof value.activeVersion !== "string" ||
142 !isExactServiceVersion(value.activeVersion) ||
143 (value.update !== undefined && update === undefined) ||
144 (update !== undefined &&
145 compareExactServiceVersions(update.targetVersion, update.fromVersion) <= 0) ||
146 (update?.status === "pending" && update.fromVersion !== value.activeVersion) ||
147 (update?.status === "committed" && update.targetVersion !== value.activeVersion) ||
148 ((update?.status === "rolled-back" || update?.status === "failed") &&
149 update.fromVersion !== value.activeVersion)
150 ) {
151 return undefined;
152 }
153 return {
154 protocol: SERVICE_LAUNCHER_PROTOCOL,
155 activeVersion: value.activeVersion,
156 ...(update === undefined ? {} : { update }),
157 };
158}
159
160export function parseServiceState(value: string): ServiceState | undefined {
161 try {
162 return decodeServiceState(JSON.parse(value) as unknown);
163 } catch {
164 return undefined;
165 }
166}
167
168export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined {
169 let parsed: unknown;
170 try {
171 parsed = JSON.parse(value) as unknown;
172 } catch {
173 return undefined;
174 }
175 if (
176 !isRecord(parsed) ||
177 parsed.protocol !== SERVICE_LAUNCHER_PROTOCOL ||
178 typeof parsed.childVersion !== "string" ||
179 !isExactServiceVersion(parsed.childVersion)
180 ) {
181 return undefined;
182 }
183 const update = parsed.update === undefined ? undefined : decodeServiceUpdate(parsed.update);
184 if (parsed.update !== undefined && update === undefined) return undefined;
185 const selectedVersion =
186 update?.status === "pending" || update?.status === "committed"
187 ? update.targetVersion
188 : update === undefined
189 ? parsed.childVersion
190 : update.fromVersion;
191 if (parsed.childVersion !== selectedVersion) {
192 return undefined;
193 }
194 return {
195 protocol: SERVICE_LAUNCHER_PROTOCOL,
196 childVersion: parsed.childVersion,
197 ...(update === undefined ? {} : { update }),
198 };
199}
200
201export function decodeServiceLauncherChildMessage(
202 value: unknown,
203): ServiceLauncherChildMessage | undefined {
204 if (!isRecord(value)) return undefined;
205 if (value.type === "request-update" && typeof value.targetVersion === "string") {
206 return { type: value.type, targetVersion: value.targetVersion };
207 }
208 return value.type === "prepared" && typeof value.updateId === "string"
209 ? { type: value.type, updateId: value.updateId }
210 : undefined;
211}
212
213export function decodeServiceLauncherParentMessage(
214 value: unknown,
215): ServiceLauncherParentMessage | undefined {
216 if (!isRecord(value)) return undefined;
217 if (value.type === "update-rejected" && typeof value.reason === "string") {
218 return { type: value.type, reason: value.reason };
219 }
220 if (value.type === "update-accepted" && typeof value.updateId === "string") {
221 return { type: value.type, updateId: value.updateId };
222 }
223 return value.type === "committed" && typeof value.updateId === "string"
224 ? { type: value.type, updateId: value.updateId }
225 : undefined;
226}
apps/server/src/environment/ServerEnvironment.ts+5-4
......@@ -10,6 +10,7 @@ import * as Schema from "effect/Schema";
1010
1111import packageJson from "../../package.json" with { type: "json" };
1212import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
13import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";
1314import * as ServerConfig from "../config.ts";
1415import * as ProcessRunner from "../processRunner.ts";
1516import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
......@@ -125,8 +126,10 @@ export const make = Effect.gen(function* () {
125126 const environmentId = EnvironmentId.make(environmentIdRaw);
126127 const cwdBaseName = path.basename(serverConfig.cwd).trim();
127128 const label = yield* resolveServerEnvironmentLabel({ cwdBaseName });
128 const serverSelfUpdate = yield* resolveServerSelfUpdateCapability({
129 const launcher = yield* resolveServiceLauncherMode();
130 const serverSelfUpdate = resolveServerSelfUpdateCapability({
129131 desktopManaged: serverConfig.mode === "desktop",
132 launcherManaged: launcher.managed,
130133 });
131134
132135 const descriptor: ExecutionEnvironmentDescriptor = {
......@@ -144,9 +147,7 @@ export const make = Effect.gen(function* () {
144147 threadSnooze: true,
145148 threadTitleRegeneration: true,
146149 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
147 ...(serverSelfUpdate === "boot-service" || serverSelfUpdate === "respawn"
148 ? { serverSelfUpdateProgress: true }
149 : {}),
150 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
150151 },
151152 };
152153
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-2
......@@ -29,6 +29,7 @@ import {
2929import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
3030import { ProviderService } from "../../provider/Services/ProviderService.ts";
3131import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
32import { forkParked } from "../../serverActivation.ts";
3233import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3334import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
3435import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
......@@ -912,7 +913,7 @@ const make = Effect.gen(function* () {
912913 const worker = yield* makeDrainableWorker(processInputSafely);
913914
914915 const start: CheckpointReactorShape["start"] = Effect.fn("start")(function* () {
915 yield* Effect.forkScoped(
916 yield* forkParked(
916917 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
917918 if (
918919 event.type !== "thread.turn-start-requested" &&
......@@ -926,7 +927,7 @@ const make = Effect.gen(function* () {
926927 }),
927928 );
928929
929 yield* Effect.forkScoped(
930 yield* forkParked(
930931 Stream.runForEach(providerService.streamEvents, (event) => {
931932 if (event.type !== "turn.started" && event.type !== "turn.completed") {
932933 return Effect.void;
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+39-15
......@@ -38,6 +38,7 @@ import {
3838 ProviderCommandReactor,
3939 type ProviderCommandReactorShape,
4040} from "../Services/ProviderCommandReactor.ts";
41import { forkParked, ServerActivation } from "../../serverActivation.ts";
4142import {
4243 resolveSourceControlWriterModelSelection,
4344 ServerSettingsService,
......@@ -899,19 +900,25 @@ const make = Effect.gen(function* () {
899900 ...(input.title !== undefined ? { title: input.title } : {}),
900901 });
901902 });
902 const clearInterruptedThreadTitleRegenerations = Effect.fn(
903 "clearInterruptedThreadTitleRegenerations",
903 const findInterruptedThreadTitleRegenerations = Effect.fn(
904 "findInterruptedThreadTitleRegenerations",
904905 )(function* () {
905906 const readModel = yield* projectionSnapshotQuery.getCommandReadModel();
907 return readModel.threads.flatMap((thread) => {
908 const requestId = thread.titleRegeneration?.requestId;
909 return requestId === undefined ? [] : [{ threadId: thread.id, requestId }];
910 });
911 });
912 const clearInterruptedThreadTitleRegenerations = Effect.fn(
913 "clearInterruptedThreadTitleRegenerations",
914 )(function* (
915 interrupted: ReadonlyArray<{ readonly threadId: ThreadId; readonly requestId: CommandId }>,
916 ) {
906917 yield* Effect.forEach(
907 readModel.threads,
908 (thread) => {
909 const requestId = thread.titleRegeneration?.requestId;
910 if (requestId === undefined) {
911 return Effect.void;
912 }
918 interrupted,
919 ({ threadId, requestId }) => {
913920 return dispatchThreadTitleRegenerationCompletion({
914 threadId: thread.id,
921 threadId,
915922 requestId,
916923 }).pipe(
917924 Effect.catchCause((cause) => {
......@@ -921,7 +928,7 @@ const make = Effect.gen(function* () {
921928 return Effect.logWarning(
922929 "provider command reactor failed to clear interrupted title regeneration",
923930 {
924 threadId: thread.id,
931 threadId,
925932 cause: Cause.pretty(cause),
926933 },
927934 );
......@@ -1304,7 +1311,7 @@ const make = Effect.gen(function* () {
13041311 processDomainEvent(event).pipe(
13051312 Effect.catchCause((cause) => {
13061313 if (Cause.hasInterruptsOnly(cause)) {
1307 return Effect.failCause(cause);
1314 return Effect.interrupt;
13081315 }
13091316 return Effect.logWarning("provider command reactor failed to process event", {
13101317 eventType: event.type,
......@@ -1316,6 +1323,17 @@ const make = Effect.gen(function* () {
13161323 const worker = yield* makeDrainableWorker(processDomainEventSafely);
13171324
13181325 const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () {
1326 const interruptedTitleRegenerations = yield* findInterruptedThreadTitleRegenerations().pipe(
1327 Effect.catchCause((cause) => {
1328 if (Cause.hasInterruptsOnly(cause)) {
1329 return Effect.interrupt;
1330 }
1331 return Effect.logWarning(
1332 "provider command reactor failed to find interrupted title regenerations",
1333 { cause: Cause.pretty(cause) },
1334 ).pipe(Effect.as([]));
1335 }),
1336 );
13191337 const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) {
13201338 if (
13211339 (event.type === "thread.meta-updated" && event.payload.regenerateTitle === true) ||
......@@ -1330,14 +1348,14 @@ const make = Effect.gen(function* () {
13301348 }
13311349 });
13321350
1333 yield* Effect.forkScoped(
1334 Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent),
1335 );
1351 yield* forkParked(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent));
13361352
13371353 // The domain event stream is hot, so work pending before this reactor
13381354 // starts cannot be resumed. Correlated completions only clear the request
13391355 // captured here, leaving any newer request untouched.
1340 yield* clearInterruptedThreadTitleRegenerations().pipe(
1356 const clearInterrupted = clearInterruptedThreadTitleRegenerations(
1357 interruptedTitleRegenerations,
1358 ).pipe(
13411359 Effect.catchCause((cause) => {
13421360 if (Cause.hasInterruptsOnly(cause)) {
13431361 return Effect.interrupt;
......@@ -1350,6 +1368,12 @@ const make = Effect.gen(function* () {
13501368 );
13511369 }),
13521370 );
1371 const activation = yield* ServerActivation;
1372 if (activation === undefined) {
1373 yield* clearInterrupted;
1374 } else {
1375 yield* forkParked(clearInterrupted);
1376 }
13531377 });
13541378
13551379 return {
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+3-2
......@@ -37,6 +37,7 @@ import {
3737 ProviderRuntimeIngestionService,
3838 type ProviderRuntimeIngestionShape,
3939} from "../Services/ProviderRuntimeIngestion.ts";
40import { forkParked } from "../../serverActivation.ts";
4041import { ServerSettingsService } from "../../serverSettings.ts";
4142
4243const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
......@@ -1804,12 +1805,12 @@ const make = Effect.gen(function* () {
18041805
18051806 const start: ProviderRuntimeIngestionShape["start"] = () =>
18061807 Effect.gen(function* () {
1807 yield* Effect.forkScoped(
1808 yield* forkParked(
18081809 Stream.runForEach(providerService.streamEvents, (event) =>
18091810 worker.enqueue({ source: "runtime", event }),
18101811 ),
18111812 );
1812 yield* Effect.forkScoped(
1813 yield* forkParked(
18131814 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
18141815 if (event.type !== "thread.turn-start-requested") {
18151816 return Effect.void;
apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts+2-1
......@@ -12,6 +12,7 @@ import {
1212 ThreadDeletionReactor,
1313 type ThreadDeletionReactorShape,
1414} from "../Services/ThreadDeletionReactor.ts";
15import { forkParked } from "../../serverActivation.ts";
1516
1617type ThreadDeletedEvent = Extract<OrchestrationEvent, { type: "thread.deleted" }>;
1718
......@@ -80,7 +81,7 @@ const make = Effect.gen(function* () {
8081 const worker = yield* makeDrainableWorker(processThreadDeletedSafely);
8182
8283 const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () {
83 yield* Effect.forkScoped(
84 yield* forkParked(
8485 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
8586 if (event.type !== "thread.deleted") {
8687 return Effect.void;
apps/server/src/persistence/Layers/Sqlite.ts+20-11
......@@ -7,6 +7,7 @@ import type { SqlError } from "effect/unstable/sql/SqlError";
77
88import { runMigrations } from "../Migrations.ts";
99import { ServerConfig } from "../../config.ts";
10import * as ServiceLauncherClient from "../../cloud/serviceLauncherClient.ts";
1011
1112type RuntimeSqliteLayerConfig = {
1213 readonly filename: string;
......@@ -30,24 +31,28 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (
3031 return clientModule.layer(config);
3132}, Layer.unwrap);
3233
33const setup = Layer.effectDiscard(
34 Effect.gen(function* () {
35 const sql = yield* SqlClient.SqlClient;
36 yield* sql`PRAGMA journal_mode = WAL;`;
37 yield* sql`PRAGMA foreign_keys = ON;`;
38 yield* runMigrations();
39 }),
40);
34const setup = (trial: boolean) =>
35 Layer.effectDiscard(
36 Effect.gen(function* () {
37 const sql = yield* SqlClient.SqlClient;
38 yield* sql`PRAGMA foreign_keys = ON;`;
39 if (!trial) {
40 yield* sql`PRAGMA journal_mode = WAL;`;
41 yield* runMigrations();
42 }
43 }),
44 );
4145
4246export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(function* (
4347 dbPath: string,
48 options?: { readonly trial?: boolean },
4449) {
4550 const fs = yield* FileSystem.FileSystem;
4651 const path = yield* Path.Path;
4752 yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });
4853
4954 return Layer.provideMerge(
50 setup,
55 setup(options?.trial === true),
5156 makeRuntimeSqliteLayer({
5257 filename: dbPath,
5358 spanAttributes: {
......@@ -59,10 +64,14 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(
5964}, Layer.unwrap);
6065
6166export const SqlitePersistenceMemory = Layer.provideMerge(
62 setup,
67 setup(false),
6368 makeRuntimeSqliteLayer({ filename: ":memory:" }),
6469);
6570
6671export const layerConfig = Layer.unwrap(
67 Effect.map(Effect.service(ServerConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)),
72 Effect.gen(function* () {
73 const { dbPath } = yield* ServerConfig;
74 const launcher = yield* ServiceLauncherClient.resolveServiceLauncherMode();
75 return makeSqlitePersistenceLive(dbPath, { trial: launcher.trial });
76 }),
6877);
apps/server/src/persistence/Migrations.ts+3-1
......@@ -9,8 +9,8 @@
99 */
1010
1111import * as Migrator from "effect/unstable/sql/Migrator";
12import * as Layer from "effect/Layer";
1312import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
1414
1515// Import all migrations statically
1616import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
......@@ -97,6 +97,8 @@ export const migrationEntries = [
9797 [35, "ProjectionThreadTitleRegeneration", Migration0035],
9898] as const;
9999
100export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
101
100102export const makeMigrationLoader = (throughId?: number) =>
101103 Migrator.fromRecord(
102104 Object.fromEntries(
apps/server/src/provider/Layers/ProviderSessionReaper.ts+2-1
......@@ -11,6 +11,7 @@ import {
1111 ProviderSessionReaper,
1212 type ProviderSessionReaperShape,
1313} from "../Services/ProviderSessionReaper.ts";
14import { forkParked } from "../../serverActivation.ts";
1415import { ProviderService } from "../Services/ProviderService.ts";
1516
1617const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000;
......@@ -105,7 +106,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =
105106
106107 const start: ProviderSessionReaperShape["start"] = () =>
107108 Effect.gen(function* () {
108 yield* Effect.forkScoped(
109 yield* forkParked(
109110 sweep.pipe(
110111 Effect.catch((error: unknown) =>
111112 Effect.logWarning("provider.session.reaper.sweep-failed", {
apps/server/src/relay/AgentAwarenessRelay.ts+3-2
......@@ -44,6 +44,7 @@ import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environme
4444import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
4545import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
4646import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
47import { forkParked } from "../serverActivation.ts";
4748
4849export class AgentAwarenessRelay extends Context.Service<
4950 AgentAwarenessRelay,
......@@ -599,12 +600,12 @@ export const make = Effect.gen(function* () {
599600 });
600601 break;
601602 }
602 yield* Effect.forkScoped(
603 yield* forkParked(
603604 Effect.sleep("1 second").pipe(
604605 Effect.andThen(publishActiveThreadsOnceWhenConfigured(startupState !== "enabled")),
605606 ),
606607 );
607 yield* Effect.forkScoped(
608 yield* forkParked(
608609 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
609610 const threadId = eventThreadId(event);
610611 if (threadId === null) {
apps/server/src/server.test.ts+41-4
......@@ -89,6 +89,7 @@ import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
8989import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
9090import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
9191import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
92import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
9293import * as ServerSettings from "./serverSettings.ts";
9394import * as TerminalManager from "./terminal/Manager.ts";
9495import * as PreviewManager from "./preview/Manager.ts";
......@@ -550,10 +551,13 @@ const buildAppUnderTest = (options?: {
550551 ),
551552 );
552553
553 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {
554 disableListenLog: true,
555 disableLogger: true,
556 }).pipe(
554 const servedRoutesLayer = HttpRouter.serve(
555 makeRoutesLayer.pipe(Layer.provide(ServiceLauncherClient.layer)),
556 {
557 disableListenLog: true,
558 disableLogger: true,
559 },
560 ).pipe(
557561 Layer.provide(
558562 Layer.mock(Keybindings.Keybindings)({
559563 loadConfigState: Effect.succeed({
......@@ -1318,6 +1322,39 @@ const getWsServerUrl = (
13181322 });
13191323
13201324it.layer(NodeServices.layer)("server router seam", (it) => {
1325 it.effect("parks HTTP ingress until command readiness", () =>
1326 Effect.gen(function* () {
1327 const fileSystem = yield* FileSystem.FileSystem;
1328 const path = yield* Path.Path;
1329 const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-gate-" });
1330 yield* fileSystem.writeFileString(path.join(staticDir, "index.html"), "ready");
1331 const entered = yield* Deferred.make<void>();
1332 const ready = yield* Deferred.make<void>();
1333 const completed = yield* Deferred.make<void>();
1334
1335 yield* buildAppUnderTest({
1336 config: { staticDir },
1337 layers: {
1338 serverRuntimeStartup: {
1339 awaitCommandReady: Deferred.succeed(entered, undefined).pipe(
1340 Effect.andThen(Deferred.await(ready)),
1341 ),
1342 },
1343 },
1344 });
1345 const request = yield* HttpClient.get("/").pipe(
1346 Effect.tap(() => Deferred.succeed(completed, undefined)),
1347 Effect.forkChild,
1348 );
1349 yield* Deferred.await(entered);
1350 assert.isFalse(yield* Deferred.isDone(completed));
1351
1352 yield* Deferred.succeed(ready, undefined);
1353 assert.equal((yield* Fiber.join(request)).status, 200);
1354 assert.isTrue(yield* Deferred.isDone(completed));
1355 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1356 );
1357
13211358 it.effect("serves static index content for GET / when staticDir is configured", () =>
13221359 Effect.gen(function* () {
13231360 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/server.ts+102-60
......@@ -1,5 +1,6 @@
11import { EnvironmentHttpApi } from "@t3tools/contracts";
22import * as Duration from "effect/Duration";
3import * as Deferred from "effect/Deferred";
34import * as Effect from "effect/Effect";
45import * as Layer from "effect/Layer";
56import * as Schedule from "effect/Schedule";
......@@ -91,6 +92,7 @@ import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"
9192import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
9293import * as CloudCliState from "./cloud/CliState.ts";
9394import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
95import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
9496import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
9597import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
9698import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
......@@ -109,6 +111,7 @@ import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
109111import * as NetService from "@t3tools/shared/Net";
110112import * as RelayClient from "@t3tools/shared/relayClient";
111113import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
114import { forkParked, ServerActivation } from "./serverActivation.ts";
112115
113116// Effect's default preemptive shutdown waits 20s before finalizing request scopes.
114117// T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer
......@@ -396,8 +399,12 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
396399 Layer.provide(NetService.layer),
397400);
398401
399const RuntimeServicesLive = ServerRuntimeStartup.layer.pipe(
400 Layer.provideMerge(RuntimeDependenciesLive),
402const commandReadinessLayer = HttpRouter.middleware(
403 (httpEffect) =>
404 Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) =>
405 startup.awaitCommandReady.pipe(Effect.orDie, Effect.andThen(httpEffect)),
406 ),
407 { global: true },
401408);
402409
403410export const makeRoutesLayer = Layer.mergeAll(
......@@ -418,6 +425,7 @@ export const makeRoutesLayer = Layer.mergeAll(
418425).pipe(
419426 Layer.provide(PreviewAutomationBroker.layer),
420427 Layer.provide(ServerSelfUpdate.layer),
428 Layer.provide(commandReadinessLayer),
421429 Layer.provide(browserApiCorsLayer),
422430 Layer.provide(httpCompressionLayer),
423431);
......@@ -425,6 +433,14 @@ export const makeRoutesLayer = Layer.mergeAll(
425433export const makeServerLayer = Layer.unwrap(
426434 Effect.gen(function* () {
427435 const config = yield* ServerConfig.ServerConfig;
436 const activation = yield* Deferred.make<void>();
437 const awaitActivation = Deferred.await(activation);
438 const activationLayer = Layer.succeed(ServerActivation, awaitActivation);
439 const runtimeStateParked = yield* Deferred.make<void>();
440 const tailscaleParked = yield* Deferred.make<void>();
441 const cloudLinkParked = yield* Deferred.make<void>();
442 const routesReady = yield* Deferred.make<void>();
443 const launcherLayer = ServiceLauncherClient.layer;
428444
429445 yield* fixPath();
430446
......@@ -438,6 +454,8 @@ export const makeServerLayer = Layer.unwrap(
438454 const runtimeStateLayer = Layer.effectDiscard(
439455 Effect.acquireRelease(
440456 Effect.gen(function* () {
457 yield* Deferred.succeed(runtimeStateParked, undefined).pipe(Effect.orDie);
458 yield* awaitActivation;
441459 const server = yield* HttpServer.HttpServer;
442460 const address = server.address;
443461 if (typeof address === "string" || !("port" in address)) {
......@@ -451,15 +469,26 @@ export const makeServerLayer = Layer.unwrap(
451469 yield* persistServerRuntimeState({
452470 path: config.serverRuntimeStatePath,
453471 state,
454 });
472 }).pipe(
473 Effect.catchCause((cause) =>
474 Effect.logWarning("Failed to persist server runtime state", { cause }),
475 ),
476 );
455477 }),
456 () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath),
478 () =>
479 clearPersistedServerRuntimeState(config.serverRuntimeStatePath).pipe(
480 Effect.catchCause((cause) =>
481 Effect.logWarning("Failed to clear server runtime state", { cause }),
482 ),
483 ),
457484 ),
458485 );
459486 const tailscaleServeLayer = config.tailscaleServeEnabled
460487 ? Layer.effectDiscard(
461488 Effect.acquireRelease(
462489 Effect.gen(function* () {
490 yield* Deferred.succeed(tailscaleParked, undefined).pipe(Effect.orDie);
491 yield* awaitActivation;
463492 const server = yield* HttpServer.HttpServer;
464493 const address = server.address;
465494 if (typeof address === "string" || !("port" in address)) {
......@@ -509,68 +538,80 @@ export const makeServerLayer = Layer.unwrap(
509538 : Layer.empty;
510539 const cloudDesiredLinkReconcileLayer = Layer.effectDiscard(
511540 Effect.gen(function* () {
512 if (!hasCloudPublicConfig) return;
513 // Idle Cloudflare tunnels are billed, so a stopping server releases its
514 // tunnel; the persisted desired link brings one back — same hostname,
515 // fresh tunnel — when the environment starts again. Registered even
516 // when no link is desired yet: a client can link a running server, and
517 // that tunnel needs the same disposal on shutdown.
518 yield* Effect.addFinalizer(() =>
519 releaseManagedTunnelOnShutdown().pipe(
520 Effect.timeout("10 seconds"),
521 Effect.tap((released) =>
522 released ? Effect.logInfo("Released the managed tunnel on shutdown") : Effect.void,
523 ),
524 Effect.catchCause((cause) =>
525 Effect.logWarning(
526 "Failed to release the managed tunnel on shutdown; the next link reuses it",
527 { cause },
528 ),
529 ),
530 Effect.asVoid,
531 ),
532 );
533 if (!(yield* CloudCliState.readCliDesiredCloudLink)) return;
534 const server = yield* HttpServer.HttpServer;
535 const address = server.address;
536 if (typeof address === "string" || !("port" in address)) return;
537 yield* Effect.forkScoped(
538 Effect.sleep("250 millis").pipe(
539 Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)),
540 // On reboot this races NIC/DNS bring-up, so back off exponentially
541 // (capped at 30s) instead of burning all retries in a second.
542 // Bounded overall so a permanently broken setup still surfaces the
543 // warning below. Bad-request/unauthorized/conflict are
544 // deterministic failures (malformed origin, not linked yet, linked
545 // to a different cloud account) that no amount of retrying
546 // converges.
547 Effect.retry({
548 while: (error) =>
549 error._tag !== "EnvironmentHttpBadRequestError" &&
550 error._tag !== "EnvironmentHttpUnauthorizedError" &&
551 error._tag !== "EnvironmentHttpConflictError",
552 schedule: Schedule.exponential("1 second").pipe(
553 Schedule.modifyDelay(({ duration }) =>
554 Effect.succeed(Duration.min(duration, Duration.seconds(30))),
541 if (!hasCloudPublicConfig) {
542 yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie);
543 return;
544 }
545 yield* forkParked(
546 Effect.gen(function* () {
547 // Only an activated runtime owns the tunnel cleanup finalizer.
548 yield* Effect.addFinalizer(() =>
549 releaseManagedTunnelOnShutdown().pipe(
550 Effect.timeout("10 seconds"),
551 Effect.tap((released) =>
552 released
553 ? Effect.logInfo("Released the managed tunnel on shutdown")
554 : Effect.void,
555555 ),
556 Schedule.upTo({ duration: "10 minutes" }),
556 Effect.catchCause((cause) =>
557 Effect.logWarning(
558 "Failed to release the managed tunnel on shutdown; the next link reuses it",
559 { cause },
560 ),
561 ),
562 Effect.asVoid,
557563 ),
558 }),
559 Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")),
560 Effect.catch((cause) =>
561 Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", {
562 cause,
564 );
565 if (!(yield* CloudCliState.readCliDesiredCloudLink)) return;
566 const server = yield* HttpServer.HttpServer;
567 const address = server.address;
568 if (typeof address === "string" || !("port" in address)) return;
569 yield* Effect.sleep("250 millis").pipe(
570 Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)),
571 Effect.retry({
572 while: (error) =>
573 error._tag !== "EnvironmentHttpBadRequestError" &&
574 error._tag !== "EnvironmentHttpUnauthorizedError" &&
575 error._tag !== "EnvironmentHttpConflictError",
576 schedule: Schedule.exponential("1 second").pipe(
577 Schedule.modifyDelay(({ duration }) =>
578 Effect.succeed(Duration.min(duration, Duration.seconds(30))),
579 ),
580 Schedule.upTo({ duration: "10 minutes" }),
581 ),
563582 }),
564 ),
565 ),
583 Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")),
584 Effect.catch((cause) =>
585 Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", {
586 cause,
587 }),
588 ),
589 );
590 }),
566591 );
592 yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie);
567593 }),
568594 );
569595
596 const runtimeServicesLive = ServerRuntimeStartup.layerWithOptions({
597 activate: Deferred.succeed(activation, undefined).pipe(Effect.asVoid),
598 abort: (error) => Deferred.die(activation, error).pipe(Effect.asVoid),
599 awaitAuxiliaryParked: Effect.all(
600 [
601 Deferred.await(runtimeStateParked),
602 Deferred.await(cloudLinkParked),
603 Deferred.await(routesReady),
604 ...(config.tailscaleServeEnabled ? [Deferred.await(tailscaleParked)] : []),
605 ],
606 { concurrency: "unbounded" },
607 ).pipe(Effect.asVoid),
608 }).pipe(Layer.provideMerge(RuntimeDependenciesLive), Layer.provide(launcherLayer));
609
610 const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), {
611 disableLogger: !config.logWebSocketEvents,
612 }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie)));
570613 const serverApplicationLayer = Layer.mergeAll(
571 HttpRouter.serve(makeRoutesLayer, {
572 disableLogger: !config.logWebSocketEvents,
573 }),
614 routesLayer,
574615 httpListeningLayer,
575616 runtimeStateLayer,
576617 tailscaleServeLayer,
......@@ -578,7 +619,8 @@ export const makeServerLayer = Layer.unwrap(
578619 );
579620
580621 return serverApplicationLayer.pipe(
581 Layer.provideMerge(RuntimeServicesLive),
622 Layer.provideMerge(runtimeServicesLive),
623 Layer.provide(activationLayer),
582624 Layer.provideMerge(serverRelayBrokerTracingLayer),
583625 Layer.provideMerge(HttpResponseCompressionLive),
584626 Layer.provideMerge(HttpServerLive),
......@@ -590,5 +632,5 @@ export const makeServerLayer = Layer.unwrap(
590632 }),
591633);
592634
593// Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer.
635// The CLI supplies configuration.
594636export const runServer = Layer.launch(makeServerLayer);
apps/server/src/serverActivation.test.ts created+23
......@@ -0,0 +1,23 @@
1import { expect, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4
5import { forkParked, ServerActivation } from "./serverActivation.ts";
6
7it.effect("proves a root is parked before returning and releases it with one gate", () =>
8 Effect.scoped(
9 Effect.gen(function* () {
10 const activation = yield* Deferred.make<void>();
11 const ran = yield* Deferred.make<void>();
12
13 yield* forkParked(Deferred.succeed(ran, undefined)).pipe(
14 Effect.provideService(ServerActivation, Deferred.await(activation)),
15 );
16 expect(yield* Deferred.isDone(ran)).toBe(false);
17
18 yield* Deferred.succeed(activation, undefined);
19 yield* Deferred.await(ran);
20 expect(yield* Deferred.isDone(ran)).toBe(true);
21 }),
22 ),
23);
apps/server/src/serverActivation.ts created+26
......@@ -0,0 +1,26 @@
1import * as Context from "effect/Context";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import type * as Scope from "effect/Scope";
5
6export class ServerActivation extends Context.Reference<Effect.Effect<void> | undefined>(
7 "t3/serverActivation",
8 { defaultValue: () => undefined },
9) {}
10
11/** Forks a long-running root before commit and proves it is parked at the activation boundary. */
12export const forkParked = <A, E, R>(
13 effect: Effect.Effect<A, E, R>,
14): Effect.Effect<void, never, Scope.Scope | R> =>
15 Effect.gen(function* () {
16 const activation = yield* ServerActivation;
17 if (activation === undefined) {
18 yield* Effect.forkScoped(effect);
19 return;
20 }
21 const parked = yield* Deferred.make<void>();
22 yield* Effect.forkScoped(
23 Deferred.succeed(parked, undefined).pipe(Effect.andThen(activation), Effect.andThen(effect)),
24 );
25 yield* Deferred.await(parked);
26 });
apps/server/src/serverRuntimeStartup.ts+182-160
......@@ -34,6 +34,8 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
3434import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
3535import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
3636import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";
37import { forkParked } from "./serverActivation.ts";
38import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
3739import {
3840 formatHeadlessServeOutput,
3941 formatHostForUrl,
......@@ -288,151 +290,161 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>)
288290 Effect.withSpan(`server.startup.${phase}`),
289291 );
290292
291export const make = Effect.gen(function* () {
292 const serverConfig = yield* ServerConfig.ServerConfig;
293 const keybindings = yield* Keybindings.Keybindings;
294 const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor;
295 const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper;
296 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
297 const serverSettings = yield* ServerSettings.ServerSettingsService;
298 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
299 const crypto = yield* Crypto.Crypto;
293interface StartupOptions {
294 readonly activate?: Effect.Effect<void>;
295 readonly awaitAuxiliaryParked?: Effect.Effect<void>;
296 readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect<void>;
297}
300298
301 const commandGate = yield* makeCommandGate;
302 const httpListening = yield* Deferred.make<void>();
303 const reactorScope = yield* Scope.make("sequential");
304
305 yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void));
306
307 const startup = Effect.gen(function* () {
308 yield* Effect.logDebug("startup phase: starting keybindings runtime");
309 yield* runStartupPhase(
310 "keybindings.start",
311 keybindings.start.pipe(
312 Effect.catch((error) =>
313 Effect.logWarning("failed to start keybindings runtime", {
314 path: error.configPath,
315 detail: error.detail,
316 cause: error.cause,
317 }),
299export const make = (options?: StartupOptions) =>
300 Effect.gen(function* () {
301 const serverConfig = yield* ServerConfig.ServerConfig;
302 const keybindings = yield* Keybindings.Keybindings;
303 const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor;
304 const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper;
305 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
306 const serverSettings = yield* ServerSettings.ServerSettingsService;
307 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
308 const crypto = yield* Crypto.Crypto;
309 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
310
311 const commandGate = yield* makeCommandGate;
312 const httpListening = yield* Deferred.make<void>();
313 const reactorScope = yield* Scope.make("sequential");
314
315 yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void));
316
317 const startup = Effect.gen(function* () {
318 yield* Effect.logDebug("startup phase: starting keybindings runtime");
319 yield* runStartupPhase(
320 "keybindings.start",
321 keybindings.start.pipe(
322 Effect.catch((error) =>
323 Effect.logWarning("failed to start keybindings runtime", {
324 path: error.configPath,
325 detail: error.detail,
326 cause: error.cause,
327 }),
328 ),
318329 ),
319 Effect.forkScoped,
320 ),
321 );
330 );
322331
323 yield* Effect.logDebug("startup phase: starting server settings runtime");
324 yield* runStartupPhase(
325 "settings.start",
326 serverSettings.start.pipe(
327 Effect.catch((error) =>
328 Effect.logWarning("failed to start server settings runtime", {
329 path: error.settingsPath,
330 operation: error.operation,
331 providerInstanceId: error.providerInstanceId,
332 environmentVariable: error.environmentVariable,
333 cause: error.cause,
334 }),
332 yield* Effect.logDebug("startup phase: starting server settings runtime");
333 yield* runStartupPhase(
334 "settings.start",
335 serverSettings.start.pipe(
336 Effect.catch((error) =>
337 Effect.logWarning("failed to start server settings runtime", {
338 path: error.settingsPath,
339 operation: error.operation,
340 providerInstanceId: error.providerInstanceId,
341 environmentVariable: error.environmentVariable,
342 cause: error.cause,
343 }),
344 ),
335345 ),
336 Effect.forkScoped,
337 ),
338 );
346 );
339347
340 yield* Effect.logDebug("startup phase: starting orchestration reactors");
341 yield* runStartupPhase(
342 "reactors.start",
343 Effect.gen(function* () {
344 yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope));
345 yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope));
346 }),
347 );
348 yield* Effect.logDebug("startup phase: parking orchestration roots at activation");
349 yield* runStartupPhase(
350 "reactors.start",
351 Effect.gen(function* () {
352 yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope));
353 yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope));
354 }),
355 );
348356
349 const welcomeBase = yield* resolveWelcomeBase;
350 const environment = yield* serverEnvironment.getDescriptor;
351 yield* Effect.logDebug("startup phase: preparing welcome payload");
352 yield* Effect.logDebug("startup phase: publishing welcome event", {
353 environmentId: environment.environmentId,
354 cwd: welcomeBase.cwd,
355 projectName: welcomeBase.projectName,
356 });
357 yield* runStartupPhase(
358 "welcome.publish",
359 lifecycleEvents.publish({
360 version: 1,
361 type: "welcome",
362 payload: {
363 environment,
364 ...welcomeBase,
365 },
366 }),
367 );
357 const welcomeBase = yield* resolveWelcomeBase;
358 const environment = yield* serverEnvironment.getDescriptor;
359 yield* Effect.logDebug("startup phase: preparing welcome payload");
360
361 if (serverConfig.autoBootstrapProjectFromCwd) {
362 yield* forkParked(
363 runStartupPhase(
364 "welcome.autobootstrap",
365 Effect.gen(function* () {
366 const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe(
367 Effect.provideService(Crypto.Crypto, crypto),
368 );
369 if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) {
370 return;
371 }
372
373 yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", {
374 environmentId: environment.environmentId,
375 cwd: welcomeBase.cwd,
376 projectName: welcomeBase.projectName,
377 bootstrapProjectId: bootstrapTargets.bootstrapProjectId,
378 bootstrapThreadId: bootstrapTargets.bootstrapThreadId,
379 });
380 yield* lifecycleEvents.publish({
381 version: 1,
382 type: "welcome",
383 payload: {
384 environment,
385 ...welcomeBase,
386 ...bootstrapTargets,
387 },
388 });
389 }).pipe(
390 Effect.catch((cause) =>
391 Effect.logWarning("startup auto-bootstrap welcome failed", {
392 cause,
393 }),
394 ),
395 ),
396 ),
397 );
398 }
368399
369 if (serverConfig.autoBootstrapProjectFromCwd) {
370 yield* Effect.forkScoped(
371 runStartupPhase(
372 "welcome.autobootstrap",
373 Effect.gen(function* () {
374 const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe(
375 Effect.provideService(Crypto.Crypto, crypto),
400 yield* forkParked(
401 Effect.gen(function* () {
402 yield* Effect.logDebug("startup phase: recording startup heartbeat");
403 yield* recordStartupHeartbeat.pipe(
404 Effect.annotateSpans({ "startup.phase": "heartbeat.record" }),
405 Effect.withSpan("server.startup.heartbeat.record"),
406 Effect.ignoreCause({ log: true }),
407 );
408 if (serverConfig.startupPresentation === "headless") {
409 const accessInfo = yield* issueHeadlessServeAccessInfo();
410 yield* runStartupPhase(
411 "headless.output",
412 Console.log(formatHeadlessServeOutput(accessInfo)),
376413 );
377 if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) {
378 return;
414 } else {
415 const startupBrowserTarget = yield* resolveStartupBrowserTarget;
416 if (serverConfig.mode !== "desktop") {
417 yield* Effect.logInfo(
418 "Authentication required. Open T3 Code using the pairing URL.",
419 ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget }));
379420 }
421 yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget));
422 }
423 }),
424 );
380425
381 yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", {
382 environmentId: environment.environmentId,
383 cwd: welcomeBase.cwd,
384 projectName: welcomeBase.projectName,
385 bootstrapProjectId: bootstrapTargets.bootstrapProjectId,
386 bootstrapThreadId: bootstrapTargets.bootstrapThreadId,
387 });
388 yield* lifecycleEvents.publish({
389 version: 1,
390 type: "welcome",
391 payload: {
392 environment,
393 ...welcomeBase,
394 ...bootstrapTargets,
395 },
396 });
397 }).pipe(
398 Effect.catch((cause) =>
399 Effect.logWarning("startup auto-bootstrap welcome failed", {
400 cause,
401 }),
402 ),
403 ),
404 ),
426 yield* Effect.logDebug("startup phase: waiting for http listener");
427 yield* runStartupPhase("http.wait", Deferred.await(httpListening));
428 yield* runStartupPhase(
429 "auxiliary-roots.parked",
430 options?.awaitAuxiliaryParked ?? Effect.void,
405431 );
406 }
407 }).pipe(
408 Effect.annotateSpans({
409 "server.mode": serverConfig.mode,
410 "server.port": serverConfig.port,
411 "server.host": serverConfig.host ?? "default",
412 }),
413 Effect.withSpan("server.startup", { kind: "server", root: true }),
414 );
415432
416 yield* Effect.forkScoped(
417 Effect.gen(function* () {
418 const startupExit = yield* Effect.exit(startup);
419 if (Exit.isFailure(startupExit)) {
420 const error = new ServerRuntimeStartupError({
421 mode: serverConfig.mode,
422 host: serverConfig.host ?? null,
423 port: serverConfig.port,
424 cause: startupExit.cause,
425 });
426 yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause });
427 yield* commandGate.failCommandReady(error);
428 return;
429 }
433 // This is the prepared boundary. Every dependency has been acquired and
434 // every runtime root has confirmed that it is parked before this request.
435 const updateOutcome = yield* launcher.prepareTrial;
436 yield* runStartupPhase(
437 "welcome.publish",
438 lifecycleEvents.publish({
439 version: 1,
440 type: "welcome",
441 payload: { environment, ...welcomeBase },
442 }),
443 );
444 yield* options?.activate ?? Effect.void;
430445
431446 yield* Effect.logDebug("Accepting commands");
432447 yield* commandGate.signalCommandReady;
433 yield* Effect.logDebug("startup phase: waiting for http listener");
434 yield* runStartupPhase("http.wait", Deferred.await(httpListening));
435 yield* Effect.logDebug("startup phase: publishing ready event");
436448 yield* runStartupPhase(
437449 "ready.publish",
438450 lifecycleEvents.publish({
......@@ -440,39 +452,49 @@ export const make = Effect.gen(function* () {
440452 type: "ready",
441453 payload: {
442454 at: DateTime.formatIso(yield* DateTime.now),
443 environment: yield* serverEnvironment.getDescriptor,
455 environment,
456 ...(updateOutcome === undefined ? {} : { updateOutcome }),
444457 },
445458 }),
446459 );
447
448 yield* Effect.logDebug("startup phase: recording startup heartbeat");
449 yield* launchStartupHeartbeat;
450 if (serverConfig.startupPresentation === "headless") {
451 yield* Effect.logDebug("startup phase: headless access info");
452 const accessInfo = yield* issueHeadlessServeAccessInfo();
453 yield* runStartupPhase(
454 "headless.output",
455 Console.log(formatHeadlessServeOutput(accessInfo)),
456 );
457 } else {
458 yield* Effect.logDebug("startup phase: browser open check");
459 const startupBrowserTarget = yield* resolveStartupBrowserTarget;
460 if (serverConfig.mode !== "desktop") {
461 yield* Effect.logInfo(
462 "Authentication required. Open T3 Code using the pairing URL.",
463 ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget }));
464 }
465 yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget));
466 }
467460 yield* Effect.logDebug("startup phase: complete");
468 }),
469 );
461 }).pipe(
462 Effect.annotateSpans({
463 "server.mode": serverConfig.mode,
464 "server.port": serverConfig.port,
465 "server.host": serverConfig.host ?? "default",
466 }),
467 Effect.withSpan("server.startup", { kind: "server", root: true }),
468 );
470469
471 return {
472 awaitCommandReady: commandGate.awaitCommandReady,
473 markHttpListening: Deferred.succeed(httpListening, undefined),
474 enqueueCommand: commandGate.enqueueCommand,
475 } satisfies ServerRuntimeStartup["Service"];
476});
470 yield* Effect.forkScoped(
471 Effect.exit(startup).pipe(
472 Effect.flatMap((startupExit) => {
473 if (Exit.isSuccess(startupExit)) return Effect.void;
474 const error = new ServerRuntimeStartupError({
475 mode: serverConfig.mode,
476 host: serverConfig.host ?? null,
477 port: serverConfig.port,
478 cause: startupExit.cause,
479 });
480 return Effect.logError("server runtime startup failed", {
481 cause: startupExit.cause,
482 }).pipe(
483 Effect.andThen(commandGate.failCommandReady(error)),
484 Effect.andThen(options?.abort?.(error) ?? Effect.void),
485 );
486 }),
487 ),
488 );
489
490 return {
491 awaitCommandReady: commandGate.awaitCommandReady,
492 markHttpListening: Deferred.succeed(httpListening, undefined),
493 enqueueCommand: commandGate.enqueueCommand,
494 } satisfies ServerRuntimeStartup["Service"];
495 });
496
497export const layerWithOptions = (options?: StartupOptions) =>
498 Layer.effect(ServerRuntimeStartup, make(options));
477499
478export const layer = Layer.effect(ServerRuntimeStartup, make);
500export const layer = layerWithOptions();
apps/server/src/service-launcher.ts created+1
......@@ -0,0 +1 @@
1import "./serviceLauncher.ts";
apps/server/src/serviceLauncher.test.ts created+199
......@@ -0,0 +1,199 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6
7import { Launcher, readServiceState, writeServiceState } from "./serviceLauncher.ts";
8import {
9 compareExactServiceVersions,
10 decodeServiceState,
11 isExactServiceVersion,
12} from "./cloud/serviceProtocol.ts";
13
14it("accepts only exact semantic versions", () => {
15 for (const version of ["0.0.0", "1.2.3", "1.2.3-alpha.1", "1.2.3-0", "1.2.3+001"]) {
16 assert.isTrue(isExactServiceVersion(version), version);
17 }
18 for (const version of ["latest", "01.2.3", "1.2.3-01", "1.2.3-alpha..1", "1.2.3+."]) {
19 assert.isFalse(isExactServiceVersion(version), version);
20 }
21});
22
23it("orders exact semantic versions without treating build metadata as precedence", () => {
24 assert.equal(compareExactServiceVersions("1.2.3", "1.2.3"), 0);
25 assert.equal(compareExactServiceVersions("1.2.4", "1.2.3"), 1);
26 assert.equal(compareExactServiceVersions("2.0.0-alpha.1", "2.0.0-alpha.2"), -1);
27 assert.equal(compareExactServiceVersions("2.0.0-alpha.2", "2.0.0-alpha.beta"), -1);
28 assert.equal(compareExactServiceVersions("2.0.0-alpha-beta", "2.0.0-alpha-alpha"), 1);
29 assert.equal(compareExactServiceVersions("2.0.0", "2.0.0-rc.1"), 1);
30 assert.equal(compareExactServiceVersions("2.0.0+one", "2.0.0+two"), 0);
31});
32
33it("rejects contradictory service state", () => {
34 assert.isUndefined(
35 decodeServiceState({
36 protocol: 1,
37 activeVersion: "0.0.31",
38 update: {
39 id: "update-1",
40 fromVersion: "0.0.30",
41 targetVersion: "0.0.32",
42 status: "pending",
43 },
44 }),
45 );
46
47 assert.isUndefined(
48 decodeServiceState({
49 protocol: 1,
50 activeVersion: "1.0.0",
51 update: {
52 id: "update-2",
53 fromVersion: "1.0.0",
54 targetVersion: "0.9.0",
55 status: "pending",
56 },
57 }),
58 );
59});
60
61it.layer(NodeServices.layer)("service state persistence", (it) => {
62 it.effect("durably replaces and strictly reads one state document", () =>
63 Effect.gen(function* () {
64 const fs = yield* FileSystem.FileSystem;
65 const path = yield* Path.Path;
66 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" });
67 const statePath = path.join(root, "runtime", "service-state.json");
68 const state = {
69 protocol: 1,
70 activeVersion: "0.0.31",
71 } as const;
72
73 yield* Effect.promise(() => writeServiceState(statePath, state));
74 assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), state);
75 }),
76 );
77
78 it.effect("serializes shutdown with launcher recovery", () =>
79 Effect.gen(function* () {
80 const fs = yield* FileSystem.FileSystem;
81 const path = yield* Path.Path;
82 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" });
83 const statePath = path.join(root, "runtime", "service-state.json");
84 const versionDir = path.join(root, "runtime", "versions", "1.0.0");
85 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
86 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
87 yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n");
88 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n");
89 yield* Effect.promise(() =>
90 writeServiceState(statePath, {
91 protocol: 1,
92 activeVersion: "1.0.0",
93 }),
94 );
95
96 const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));
97 const running = launcher.run();
98 yield* Effect.promise(() => launcher.stop("SIGTERM"));
99 yield* Effect.promise(() => running);
100 }),
101 );
102
103 it.effect("commits only after the trial reports prepared", () =>
104 Effect.gen(function* () {
105 const fs = yield* FileSystem.FileSystem;
106 const path = yield* Path.Path;
107 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" });
108 const statePath = path.join(root, "runtime", "service-state.json");
109 const childSource = `
110const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT);
111if (context.update?.status === "pending") {
112 process.send({ type: "prepared", updateId: context.update.id });
113 process.on("message", (message) => {
114 if (message.type === "committed") process.exit(0);
115 });
116} else if (context.update === undefined) {
117 process.send({ type: "request-update", targetVersion: "1.1.0" });
118 setInterval(() => {}, 1_000);
119} else {
120 process.exit(0);
121}
122`;
123 for (const version of ["1.0.0", "1.1.0"]) {
124 const versionDir = path.join(root, "runtime", "versions", version);
125 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
126 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
127 yield* fs.writeFileString(entryPath, childSource);
128 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);
129 }
130 yield* Effect.promise(() =>
131 writeServiceState(statePath, {
132 protocol: 1,
133 activeVersion: "1.0.0",
134 }),
135 );
136
137 const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));
138 yield* Effect.promise(() =>
139 launcher.run().then(
140 () => Promise.reject(new Error("launcher unexpectedly completed")),
141 () => Promise.resolve(),
142 ),
143 );
144
145 const state = yield* Effect.promise(() => readServiceState(statePath));
146 assert.equal(state.activeVersion, "1.1.0");
147 assert.equal(state.update?.status, "committed");
148 }),
149 );
150
151 it.effect("rolls back a trial that reports the wrong update ID", () =>
152 Effect.gen(function* () {
153 const fs = yield* FileSystem.FileSystem;
154 const path = yield* Path.Path;
155 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-rollback-" });
156 const statePath = path.join(root, "runtime", "service-state.json");
157 const childSource = `
158const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT);
159if (context.update?.status === "pending") {
160 process.send({ type: "prepared", updateId: "wrong-update" });
161} else if (context.update === undefined) {
162 process.send({ type: "request-update", targetVersion: "1.1.0" });
163 setInterval(() => {}, 1_000);
164} else {
165 process.exit(0);
166}
167`;
168 for (const version of ["1.0.0", "1.1.0"]) {
169 const versionDir = path.join(root, "runtime", "versions", version);
170 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
171 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
172 yield* fs.writeFileString(entryPath, childSource);
173 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);
174 }
175 yield* Effect.promise(() =>
176 writeServiceState(statePath, {
177 protocol: 1,
178 activeVersion: "1.0.0",
179 }),
180 );
181
182 const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));
183 yield* Effect.promise(() =>
184 launcher.run().then(
185 () => Promise.reject(new Error("launcher unexpectedly completed")),
186 () => Promise.resolve(),
187 ),
188 );
189
190 const state = yield* Effect.promise(() => readServiceState(statePath));
191 assert.equal(state.activeVersion, "1.0.0");
192 assert.equal(state.update?.status, "rolled-back");
193 assert.equal(
194 state.update?.status === "rolled-back" ? state.update.reason : undefined,
195 "invalid-prepared",
196 );
197 }),
198 );
199});
apps/server/src/serviceLauncher.ts created+458
......@@ -0,0 +1,458 @@
1// @effect-diagnostics nodeBuiltinImport:off
2// @effect-diagnostics globalDate:off
3// @effect-diagnostics globalTimers:off
4// This file is shipped as a standalone bundle and copied to a stable path by
5// `t3 service update`. Keep runtime imports limited to Node built-ins.
6import * as NodeChildProcess from "node:child_process";
7import * as NodeCrypto from "node:crypto";
8import * as NodeFSP from "node:fs/promises";
9import * as NodePath from "node:path";
10
11import type {
12 PendingServiceUpdate,
13 ServiceLauncherChildMessage,
14 ServiceLauncherContext,
15 ServiceLauncherParentMessage,
16 ServiceState,
17 ServiceUpdateRecord,
18} from "./cloud/serviceProtocol.ts";
19import {
20 compareExactServiceVersions,
21 decodeServiceLauncherChildMessage,
22 isExactServiceVersion,
23 parseServiceState,
24 SERVICE_LAUNCHER_CONTEXT_ENV,
25 SERVICE_LAUNCHER_PROTOCOL,
26 SERVICE_STATE_FILE,
27} from "./cloud/serviceProtocol.ts";
28
29const HANDOFF_DELAY_MS = 2_000;
30const PREPARED_TIMEOUT_MS = 120_000;
31const TERMINATE_GRACE_MS = 5_000;
32
33type TerminalStatus = "committed" | "rolled-back" | "failed";
34type ChildRole = "active" | "trial";
35
36interface ManagedChild {
37 readonly version: string;
38 role: ChildRole;
39 readonly process: NodeChildProcess.ChildProcess;
40}
41
42const runtimePaths = (baseDir: string, version: string) => {
43 const versionDir = NodePath.join(baseDir, "runtime", "versions", version);
44 return {
45 versionDir,
46 entryPath: NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),
47 sentinelPath: NodePath.join(versionDir, ".install-complete"),
48 };
49};
50
51export async function readServiceState(filePath: string): Promise<ServiceState> {
52 const contents = await NodeFSP.readFile(filePath, "utf8");
53 const state = parseServiceState(contents);
54 if (state === undefined) throw new Error("Service state is invalid or unsupported.");
55 return state;
56}
57
58/** Durable same-directory replacement used for every runtime state transition. */
59export async function writeServiceState(filePath: string, state: ServiceState): Promise<void> {
60 const directory = NodePath.dirname(filePath);
61 await NodeFSP.mkdir(directory, { recursive: true, mode: 0o700 });
62 const tempPath = NodePath.join(
63 directory,
64 `.${NodePath.basename(filePath)}.${process.pid}.${NodeCrypto.randomUUID()}`,
65 );
66 let handle: NodeFSP.FileHandle | undefined;
67 try {
68 handle = await NodeFSP.open(tempPath, "wx", 0o600);
69 await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8");
70 await handle.sync();
71 await handle.close();
72 handle = undefined;
73 await NodeFSP.rename(tempPath, filePath);
74 const directoryHandle = await NodeFSP.open(directory, "r");
75 try {
76 await directoryHandle.sync();
77 } finally {
78 await directoryHandle.close();
79 }
80 } finally {
81 await handle?.close().catch(() => undefined);
82 await NodeFSP.rm(tempPath, { force: true }).catch(() => undefined);
83 }
84}
85
86async function runtimeExists(baseDir: string, version: string): Promise<boolean> {
87 const paths = runtimePaths(baseDir, version);
88 try {
89 const [entry, sentinel] = await Promise.all([
90 NodeFSP.stat(paths.entryPath),
91 NodeFSP.readFile(paths.sentinelPath, "utf8"),
92 ]);
93 return entry.isFile() && sentinel.trim() === version;
94 } catch {
95 return false;
96 }
97}
98
99function terminalUpdate<S extends TerminalStatus>(input: {
100 readonly pending: PendingServiceUpdate;
101 readonly status: S;
102 readonly reason?: string;
103}): Exclude<ServiceUpdateRecord, PendingServiceUpdate> & { readonly status: S } {
104 return {
105 id: input.pending.id,
106 fromVersion: input.pending.fromVersion,
107 targetVersion: input.pending.targetVersion,
108 status: input.status,
109 ...(input.reason === undefined ? {} : { reason: input.reason }),
110 };
111}
112
113function sendMessage(
114 child: NodeChildProcess.ChildProcess,
115 message: ServiceLauncherParentMessage,
116): Promise<void> {
117 return new Promise((resolve, reject) => {
118 if (!child.connected || child.send === undefined) {
119 reject(new Error("service child IPC is disconnected."));
120 return;
121 }
122 child.send(message, (error) => (error === null ? resolve() : reject(error)));
123 });
124}
125
126function waitForExit(child: NodeChildProcess.ChildProcess): Promise<void> {
127 if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
128 return new Promise((resolve) => child.once("exit", () => resolve()));
129}
130
131async function terminateChild(
132 child: NodeChildProcess.ChildProcess,
133 signal: NodeJS.Signals = "SIGTERM",
134): Promise<void> {
135 if (child.exitCode !== null || child.signalCode !== null) return;
136 child.kill(signal);
137 const force = setTimeout(() => child.kill("SIGKILL"), TERMINATE_GRACE_MS);
138 try {
139 await waitForExit(child);
140 } finally {
141 clearTimeout(force);
142 }
143}
144
145export class Launcher {
146 readonly #baseDir: string;
147 readonly #statePath: string;
148 #state: ServiceState;
149 #child: ManagedChild | null = null;
150 #timer: NodeJS.Timeout | undefined;
151 #transitions: Promise<void> = Promise.resolve();
152 #stopping = false;
153 #done = false;
154 readonly #completion = Promise.withResolvers<void>();
155
156 constructor(baseDir: string, state: ServiceState) {
157 this.#baseDir = baseDir;
158 this.#statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE);
159 this.#state = state;
160 }
161
162 async run(): Promise<void> {
163 const onSigterm = () => void this.stop("SIGTERM");
164 const onSigint = () => void this.stop("SIGINT");
165 process.once("SIGTERM", onSigterm);
166 process.once("SIGINT", onSigint);
167 try {
168 this.#enqueue(() => this.#recover());
169 await this.#completion.promise;
170 } finally {
171 process.off("SIGTERM", onSigterm);
172 process.off("SIGINT", onSigint);
173 }
174 }
175
176 #enqueue(transition: () => Promise<void>): void {
177 this.#transitions = this.#transitions
178 .then(transition, transition)
179 .catch((cause: unknown) =>
180 this.#fatal(cause instanceof Error ? cause : new Error(String(cause))),
181 );
182 }
183
184 async #fatal(error: Error): Promise<void> {
185 if (this.#done) return;
186 this.#done = true;
187 this.#stopping = true;
188 this.#clearTimer();
189 const child = this.#child?.process;
190 this.#child = null;
191 if (child !== undefined) await terminateChild(child);
192 this.#completion.reject(error);
193 }
194
195 async stop(signal: NodeJS.Signals): Promise<void> {
196 if (this.#stopping) {
197 await this.#completion.promise.catch(() => undefined);
198 return;
199 }
200 this.#stopping = true;
201 this.#clearTimer();
202 this.#enqueue(async () => {
203 const child = this.#child?.process;
204 this.#child = null;
205 if (child !== undefined) await terminateChild(child, signal);
206 this.#done = true;
207 this.#completion.resolve();
208 });
209 await this.#completion.promise.catch(() => undefined);
210 }
211
212 #clearTimer(): void {
213 clearTimeout(this.#timer);
214 this.#timer = undefined;
215 }
216
217 async #recover(): Promise<void> {
218 const update = this.#state.update;
219 if (update?.status !== "pending") {
220 await this.#startChild(this.#state.activeVersion, "active", update);
221 return;
222 }
223 if (!(await runtimeExists(this.#baseDir, update.targetVersion))) {
224 await this.#returnToPrevious(update, "failed", "target-runtime-missing");
225 return;
226 }
227 await this.#startTrial(update);
228 }
229
230 async #startTrial(pending: PendingServiceUpdate): Promise<void> {
231 try {
232 await this.#startChild(pending.targetVersion, "trial", pending);
233 } catch {
234 await this.#returnToPrevious(pending, "failed", "candidate-start-failed");
235 }
236 }
237
238 async #startChild(version: string, role: ChildRole, update?: ServiceUpdateRecord): Promise<void> {
239 if (this.#stopping) return;
240 if (!(await runtimeExists(this.#baseDir, version))) {
241 throw new Error(`Selected t3@${version} runtime is missing or incomplete.`);
242 }
243 if (this.#stopping) return;
244 const paths = runtimePaths(this.#baseDir, version);
245 const context: ServiceLauncherContext = {
246 protocol: SERVICE_LAUNCHER_PROTOCOL,
247 childVersion: version,
248 ...(update === undefined ? {} : { update }),
249 };
250 const child = NodeChildProcess.spawn(process.execPath, [paths.entryPath, "serve"], {
251 env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) },
252 stdio: ["inherit", "inherit", "inherit", "ipc"],
253 });
254 await new Promise<void>((resolve, reject) => {
255 const onError = (error: Error) => reject(error);
256 child.once("error", onError);
257 child.once("spawn", () => {
258 child.removeListener("error", onError);
259 child.on("error", (error) => this.#enqueue(() => Promise.reject(error)));
260 resolve();
261 });
262 });
263 if (this.#stopping) {
264 await terminateChild(child);
265 return;
266 }
267
268 const managed: ManagedChild = {
269 version,
270 role,
271 process: child,
272 };
273 this.#child = managed;
274 child.on("message", (value) => {
275 const message = decodeServiceLauncherChildMessage(value);
276 if (message !== undefined) this.#enqueue(() => this.#handleMessage(managed, message));
277 });
278 child.once("exit", (code, signal) =>
279 this.#enqueue(() => this.#handleExit(managed, code, signal)),
280 );
281
282 if (role === "trial") {
283 this.#timer = setTimeout(
284 () => this.#enqueue(() => this.#handlePreparedTimeout(managed)),
285 PREPARED_TIMEOUT_MS,
286 );
287 }
288 }
289
290 async #handleMessage(child: ManagedChild, message: ServiceLauncherChildMessage): Promise<void> {
291 if (this.#child !== child || this.#stopping) return;
292 if (message.type === "request-update") {
293 await this.#handleUpdateRequest(child, message);
294 return;
295 }
296 await this.#handlePrepared(child, message.updateId);
297 }
298
299 async #handleUpdateRequest(
300 child: ManagedChild,
301 message: Extract<ServiceLauncherChildMessage, { readonly type: "request-update" }>,
302 ): Promise<void> {
303 const reject = (reason: string) =>
304 sendMessage(child.process, { type: "update-rejected", reason });
305 if (child.role !== "active") {
306 await reject("Only the active server can request an update.");
307 return;
308 }
309 if (child.version !== this.#state.activeVersion) {
310 await reject("The requesting server is not the selected active version.");
311 return;
312 }
313 if (this.#state.update?.status === "pending") {
314 await reject("Another server update is already pending.");
315 return;
316 }
317 if (!isExactServiceVersion(message.targetVersion)) {
318 await reject("The requested target is not an exact version.");
319 return;
320 }
321 if (compareExactServiceVersions(message.targetVersion, child.version) <= 0) {
322 await reject("Remote updates must select a newer server version.");
323 return;
324 }
325 if (!(await runtimeExists(this.#baseDir, message.targetVersion))) {
326 await reject("The requested target runtime is missing or incomplete.");
327 return;
328 }
329
330 const pending: PendingServiceUpdate = {
331 id: NodeCrypto.randomUUID(),
332 fromVersion: child.version,
333 targetVersion: message.targetVersion,
334 status: "pending",
335 };
336 const next: ServiceState = { ...this.#state, update: pending };
337 await writeServiceState(this.#statePath, next);
338 this.#state = next;
339 await sendMessage(child.process, { type: "update-accepted", updateId: pending.id });
340 this.#timer = setTimeout(() => this.#enqueue(() => this.#beginTrial(child)), HANDOFF_DELAY_MS);
341 }
342
343 async #beginTrial(child: ManagedChild): Promise<void> {
344 const pending = this.#state.update;
345 if (this.#child !== child || child.role !== "active" || pending?.status !== "pending") {
346 return;
347 }
348 this.#timer = undefined;
349 this.#child = null;
350 await terminateChild(child.process);
351 await this.#startTrial(pending);
352 }
353
354 async #handlePrepared(child: ManagedChild, updateId: string): Promise<void> {
355 const pending = this.#state.update;
356 if (
357 child.role !== "trial" ||
358 pending?.status !== "pending" ||
359 pending.id !== updateId ||
360 pending.targetVersion !== child.version
361 ) {
362 if (child.role === "trial" && pending?.status === "pending") {
363 await this.#returnToPrevious(pending, "rolled-back", "invalid-prepared", child);
364 return;
365 }
366 throw new Error("Trial child reported prepared for an unexpected update.");
367 }
368 this.#clearTimer();
369 const committed = terminalUpdate({ pending, status: "committed" });
370 const next: ServiceState = {
371 ...this.#state,
372 activeVersion: pending.targetVersion,
373 update: committed,
374 };
375 await writeServiceState(this.#statePath, next);
376 this.#state = next;
377 child.role = "active";
378 await sendMessage(child.process, { type: "committed", updateId: committed.id });
379 }
380
381 async #handlePreparedTimeout(child: ManagedChild): Promise<void> {
382 const pending = this.#state.update;
383 if (this.#child !== child || child.role !== "trial" || pending?.status !== "pending") {
384 return;
385 }
386 this.#timer = undefined;
387 await this.#returnToPrevious(pending, "rolled-back", "prepared-timeout", child);
388 }
389
390 async #handleExit(
391 child: ManagedChild,
392 code: number | null,
393 signal: NodeJS.Signals | null,
394 ): Promise<void> {
395 if (this.#child !== child || this.#stopping) return;
396 this.#child = null;
397 if (child.role === "trial") {
398 this.#clearTimer();
399 const pending = this.#state.update;
400 if (pending?.status !== "pending") {
401 throw new Error("Trial child exited without matching pending state.");
402 }
403 await this.#returnToPrevious(
404 pending,
405 "rolled-back",
406 `candidate-exited:${String(code ?? signal ?? "unknown")}`,
407 );
408 return;
409 }
410
411 this.#clearTimer();
412 const pending = this.#state.update;
413 if (pending?.status === "pending") {
414 await this.#startTrial(pending);
415 return;
416 }
417 throw new Error(`Active child exited unexpectedly (${String(code ?? signal ?? "unknown")}).`);
418 }
419
420 async #returnToPrevious(
421 pending: PendingServiceUpdate,
422 status: "rolled-back" | "failed",
423 reason: string,
424 child?: ManagedChild,
425 ): Promise<void> {
426 const outcome = terminalUpdate({ pending, status, reason });
427 const next: ServiceState = {
428 ...this.#state,
429 activeVersion: pending.fromVersion,
430 update: outcome,
431 };
432 await writeServiceState(this.#statePath, next);
433 this.#state = next;
434 if (child !== undefined) {
435 this.#child = null;
436 await terminateChild(child.process);
437 }
438 await this.#startChild(next.activeVersion, "active", outcome);
439 }
440}
441
442async function main(): Promise<void> {
443 const baseDir = process.env.T3CODE_HOME?.trim();
444 if (baseDir === undefined || baseDir === "") {
445 throw new Error("T3CODE_HOME is required by the T3 Code service launcher.");
446 }
447 const statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE);
448 const state = await readServiceState(statePath);
449 await new Launcher(baseDir, state).run();
450}
451
452if (import.meta.main) {
453 main().catch((cause: unknown) => {
454 const error = cause instanceof Error ? cause : new Error(String(cause));
455 process.stderr.write(`[service-launcher] ${error.message}\n`);
456 process.exitCode = 1;
457 });
458}
docs/internals/server-updates.md+68-120
......@@ -2,139 +2,87 @@
22
33> For maintainers. Using T3 Code? See [docs/user](../user/).
44
5T3 Code can update a connected server to the exact version of the client that detected version
6drift. This path exists primarily for remote environments, where the user may not have a terminal
7open on the server machine.
8
9The feature has three boundaries:
10
11- the server advertises whether and how it can be replaced;
12- the client chooses the matching user action;
13- the server installs and verifies the replacement before handing off the process.
14
15## Detection and Presentation
16
17`ExecutionEnvironmentDescriptor` includes the server version and an optional
18`capabilities.serverSelfUpdate` value. Progress-capable servers also advertise
19`capabilities.serverSelfUpdateProgress`. The client compares the server version with `APP_VERSION`
20after loading server config.
21
22The optional capability is intentionally backward compatible. An older server does not know about
23the field, so a missing value means the client must offer a manual relaunch instead of sending an
24unknown RPC.
25
26The shared `ServerUpdateAction` is rendered in both user-facing version-drift surfaces:
27
28- the conversation banner in `ChatView`;
29- primary and saved environment rows in **Settings** → **Connections**.
30
31Both surfaces target the client's exact version. When the reconnected server reports that version,
32the mismatch and action disappear.
33
34The operation state lives in `packages/client-runtime`, keyed by environment. Both web surfaces read
35the same `downloading`, `installing`, or `resuming` state, so route changes do not own or cancel the
36operation.
37
38## Capability Selection
39
40The server resolves its capability once at startup and publishes it in the environment descriptor.
41
42| Advertised value | Process shape | Client behavior |
43| ----------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
44| `boot-service` | Linux server running under the T3-managed systemd user service | Call the update RPC; the service unit is replaced and restarted. |
45| `respawn` | Published npm CLI running in the foreground on macOS or Linux | Call the update RPC; the process hands off to a detached replacement. |
46| `desktop-managed` | Backend supervised by the desktop app | Tell the user to update the desktop app on the server machine. |
47| absent | Older server, development checkout, Windows foreground process, or an unrecognized supervisor | Offer the exact manual relaunch command. |
48
49Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must
50never spawn a second CLI server beside the app-owned process. Likewise, a process launched by an
51unrecognized systemd unit does not claim the foreground respawn path because its supervisor could
52bring the old version back.
53
54## Update Flow
55
56```mermaid
57flowchart TD
58 A[Client detects different versions] --> B{Advertised update path}
59 B -->|desktop-managed| C[Update desktop app on server machine]
60 B -->|missing| D[Copy exact manual relaunch command]
61 B -->|boot-service or respawn| E{Progress capability}
62 E -->|present| F[server.updateServerWithProgress]
63 E -->|missing| G[server.updateServer fallback]
64 F --> H[Install exact t3 version in pinned runtime]
65 G --> H
66 H --> I[Run version preflight]
67 I -->|bad code or version| J[Remove candidate runtime and keep current server]
68 I -->|cannot run preflight| J2[Keep candidate and current server]
69 I -->|passes| K{Handoff method}
70 K -->|boot-service| L[Rewrite and restart T3 systemd unit]
71 K -->|respawn| M[Start delayed replacement and exit current process]
72 L --> N[Reconnect with fresh backoff]
73 M --> N
74 N --> O[Replacement publishes ready at target version]
75```
5Remote server updates use one stable systemd launcher. Foreground CLI processes do not self-update,
6and a running server never edits its systemd unit or durable service state.
7
8## Ownership
9
10The service files under `<baseDir>/runtime` are:
11
12- `service-launcher.mjs`, the stable process selected by systemd;
13- `service-state.json`, the launcher's durable selection state;
14- `versions/<version>`, immutable exact-version npm installs.
15
16The launcher is the only runtime writer of `service-state.json`. `t3 service install` and
17`t3 service update` may replace the launcher and state while the unit is stopped. Server children
18only communicate with the launcher over their inherited IPC channel.
7619
77Both update RPCs require the environment's `orchestration:operate` authorization scope. Their
78payload accepts only an exact npm version, including an exact prerelease version; dist-tags such as
79`latest` and `nightly` are rejected. The unary `server.updateServer` method remains available so a
80new client can still repair skew with an older server.
20The state contains one active version and, at most, one update record:
8121
82The update service permits one update at a time. It installs `t3@<version>` under
83`<baseDir>/runtime/versions/<version>` and writes an install-complete sentinel only after npm exits
84successfully. Boot-service setup and self-update share the same process-wide installation lock, so
85they cannot mutate a pinned runtime concurrently.
22- `pending A → B` selects B as a retryable trial;
23- `committed A → B` selects B for ordinary restarts;
24- `rolled-back A → B` or `failed A → B` selects A;
25- invalid state fails closed so systemd cannot guess at a runtime.
8626
87Before any restart, the current Node executable runs the replacement with `--version`. A failed
88install, failed preflight, or wrong reported version leaves the current server running.
27Every write uses same-directory replacement plus file and directory fsync.
8928
90Candidate cleanup is narrower than "any failed preflight". The candidate runtime is removed only when
91the preflight process actually completes and reports a bad exit code or the wrong version: that is
92the case where a completed npm install produced an unusable tree, so retrying the same version must
93perform a clean install rather than reuse it. If the preflight cannot run at all, for example a spawn
94error or the `PREFLIGHT_TIMEOUT` elapsing, the update fails before reaching cleanup and the candidate
95directory is left in place.
29## Remote Update
9630
97## Host Service Lifecycle
311. The active server installs `t3@<target>` into a unique staging directory.
322. The target runs `__service-preflight` against the active database in read-only mode.
333. The staging directory is renamed to its immutable version path only after preflight succeeds.
344. The active child sends `request-update`. The launcher validates the child and target, writes
35 pending state, generates the update ID, then replies `update-accepted`.
365. After a short response-flush grace period, the launcher stops the active child.
376. The launcher starts the target as a trial and gives it the pending update over IPC.
387. The trial acquires dependencies, binds HTTP, starts every long-running root fiber, and verifies
39 that each root is parked at the activation gate. It does not run migrations.
408. The trial sends `prepared`. The launcher durably commits B before replying `committed`.
419. The child opens the existing activation gate, accepts commands, and publishes lifecycle ready
42 with the terminal update outcome.
9843
99The systemd user service is a host lifecycle concern, not a T3 Connect resource. The standalone
100`t3 service install`, `uninstall`, `update`, and `status` commands own it. Install and update both
101reconcile the unit through `BootService`; running `npx t3@latest service update` therefore pins and
102activates the latest CLI release without requiring a connected client.
44Post-commit startup does not call service `start`, `initialize`, `connect`, `load`, or `acquire`
45operations. It only opens prepared gates and publishes prepared lifecycle state.
10346
104The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation
105operation as `t3 service install`. Connect logout only disables cloud access and clears its
106authorization; it does not uninstall the host service.
47The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within
48120 seconds. If the trial exits or times out before prepared, the launcher records rollback and
49starts A. After commit, B is active and normal systemd restart policy applies.
10750
108## Process Handoff
51## Migration Boundary
52
53Remote update preflight requires exact equality between the database's applied `(id, name)` rows
54and the target release's migration manifest. A target with a missing, additional, renamed, or
55unknown migration is blocked. Remote updates never migrate or downgrade a database.
56
57This deliberately means any release containing a migration requires a local service update:
58
59```sh
60npx t3@<version> service update
61```
10962
110For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified
111runtime and reloads systemd. It acknowledges the handoff, then restarts the unit after the same
112short grace period used by foreground respawn. A rejected deferred restart restores the previous
113unit and is logged by the still-running process.
63The local command stops the unit, selects the new launcher and exact runtime, then restarts the
64service. Its normal server startup may run migrations.
11465
115For `respawn`, the server starts a detached, delayed replacement that replays the original CLI
116arguments. It then acknowledges the request and schedules the current process to exit. The delays
117give the acknowledgement time to cross direct or relayed connections before the socket closes.
66## Client Correlation
11867
119Progress-capable servers emit `downloading` before installing the pinned runtime and `installing`
120before preflight and handoff. A terminal stream event acknowledges that restart is scheduled. The
121client then enters `resuming`, waits for the replacement lifecycle stream to publish `ready` with
122the target version, and only then completes the operation. It watches for the intentional
123disconnect's first backoff state and requests one fresh retry, which clears historical backoff debt
124without adding a separate reconnect loop.
68The update acknowledgement includes the launcher-generated update ID. After reconnecting, clients
69wait for a lifecycle ready event carrying that same ID. `committed` completes the operation only
70when the ready server is the target version. `rolled-back` and `failed` end it immediately with the
71recorded reason. Older servers without an ID retain version-only reconnect behavior.
12572
126## Release Invariant
73## Capability and Compatibility
12774
128The exact client version must exist as the `t3` npm package before a client carrying that version is
129published. The release workflow therefore makes the GitHub release depend on CLI publication, and
130the hosted web deployment depends on that release. See [Release Checklist](../operations/release.md#server-self-update-release-invariant).
75The existing additive RPC and lifecycle schemas remain compatible with older clients. New servers
76advertise remote self-update only when they have valid launcher context and a live IPC channel.
77Desktop-managed servers direct the user to update the desktop app. Other process shapes provide a
78manual command; the old detached foreground respawn path no longer exists.
13179
13280## Source Map
13381
134- Capability contract: `packages/contracts/src/environment.ts`
135- Update RPC contract: `packages/contracts/src/server.ts` and `packages/contracts/src/rpc.ts`
136- Capability detection and handoff: `apps/server/src/cloud/selfUpdate.ts`
137- Host service commands: `apps/server/src/cli/service.ts`
138- Pinned runtime installation: `apps/server/src/cloud/pinnedRuntime.ts`
139- Client version comparison: `apps/web/src/versionSkew.ts`
140- Shared update action: `apps/web/src/components/ServerUpdateAction.tsx`
82- Launcher and state machine: `apps/server/src/serviceLauncher.ts`
83- IPC and durable state types: `apps/server/src/cloud/serviceProtocol.ts`
84- Child IPC adapter: `apps/server/src/cloud/serviceLauncherClient.ts`
85- Staging and preflight: `apps/server/src/cloud/pinnedRuntime.ts` and `servicePreflight.ts`
86- Service installation: `apps/server/src/cloud/bootService.ts`
87- Activation boundary: `apps/server/src/serverRuntimeStartup.ts` and `serverActivation.ts`
88- Client outcome correlation: `packages/client-runtime/src/state/server.ts`
docs/operations/release.md+4-2
......@@ -187,8 +187,10 @@ the **Update server** action targeting a package version that does not exist yet
187187
188188For a release smoke test, confirm `npm view t3@<version> version` returns the expected version, then
189189connect the new client to a server on the previous version and verify that the update action
190reconnects to the matching server. Test one automatic path and the manual or desktop-managed
191guidance when those environments are available.
190reconnects to the matching server. Use releases with identical migration manifests for the
191automatic path. When the manifest changed, verify that the remote action stops before restart and
192shows the exact local `npx t3@<version> service update` command. Also test the manual or
193desktop-managed guidance when those environments are available.
192194
193195## Desktop auto-update notes
194196
docs/user/background-service.md+4
......@@ -31,6 +31,10 @@ npx t3@latest service uninstall
3131
3232Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first.
3333
34The systemd unit runs a small stable launcher. Exact T3 Code versions are installed separately, so
35a failed remote candidate can return to the previous version without rewriting the unit. Releases
36that change the database must be installed with the local `service update` command above.
37
3438## Using It with T3 Connect
3539
3640T3 Connect may offer to install the service during setup so the host stays reachable after you log
docs/user/updating.md+7-3
......@@ -24,13 +24,17 @@ The update does not remove saved threads, settings, or project files.
2424
2525| Action | What to do |
2626| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27| **Update server** | Select the button and leave T3 Code open. It prepares the matching version, restarts the server, and reconnects automatically. This can take several minutes. |
27| **Update server** | Available for the T3 Code Linux background service. Select the button and leave T3 Code open while it prepares, tests, restarts, and reconnects. |
2828| **Update the desktop app** | Open the T3 Code desktop app on the machine that runs the server and install the app update there. Reopen it if needed. |
2929| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current T3 Code server, and relaunch it with the copied command and any startup options you normally use. |
3030
3131The available action depends on how that server was started. T3 Code does not update connected
3232servers silently in the background.
3333
34If the requested version includes a database update, remote installation stops before restart and
35asks you to run the exact `npx t3@<version> service update` command on the server machine. This is
36an intentional rollback-safety boundary.
37
3438After selecting **Update server**, the warning becomes a three-step progress rail:
3539**Download**, **Install**, and **Resume**. The same progress appears in the conversation and in
3640Connections, so navigating between them does not lose the update. A failed step remains visible
......@@ -56,8 +60,8 @@ commands.
5660## After the Update
5761
5862Keep the web or desktop app open while the server restarts. The update completes only after the
59replacement server reports the requested version and is ready to accept commands. The warning and
60progress rail then disappear.
63service launcher reports that exact update committed and the replacement server is ready to accept
64commands. A rollback is reported immediately instead of waiting for a generic reconnect timeout.
6165
6266If a step fails:
6367
packages/client-runtime/src/state/server.test.ts+36
......@@ -29,11 +29,13 @@ import {
2929 applyServerConfigProjection,
3030 makeEnvironmentServerConfigState,
3131 isLegacyUpdateHandoffLoss,
32 matchesServerUpdateReadyEvent,
3233 projectServerWelcome,
3334 resolveServerConfigValue,
3435 resolveServerUpdateProgressResult,
3536 serverUpdateStateForProgressEvent,
3637 serverUpdateStateForServerVersion,
38 validateServerUpdateReadyEvent,
3739} from "./server.ts";
3840
3941const CONFIG = {
......@@ -173,6 +175,40 @@ describe("server state projection", () => {
173175 expect(serverUpdateStateForServerVersion(failed, "0.0.31")).toEqual({ status: "idle" });
174176 });
175177
178 it.effect("correlates launcher outcomes and fails immediately after rollback", () =>
179 Effect.gen(function* () {
180 const result = {
181 targetVersion: "0.0.31",
182 method: "boot-service" as const,
183 updateId: "update-1",
184 };
185 const ready = (status: "committed" | "rolled-back") =>
186 ({
187 version: 1 as const,
188 sequence: 1,
189 type: "ready" as const,
190 payload: {
191 at: "2026-08-01T00:00:00.000Z",
192 environment: { serverVersion: status === "committed" ? "0.0.31" : "0.0.30" },
193 updateOutcome: {
194 id: "update-1",
195 fromVersion: "0.0.30",
196 targetVersion: "0.0.31",
197 status,
198 ...(status === "rolled-back" ? { reason: "prepared-timeout" } : {}),
199 },
200 },
201 }) as Parameters<typeof matchesServerUpdateReadyEvent>[1];
202
203 expect(matchesServerUpdateReadyEvent(result, ready("committed"))).toBe(true);
204 yield* validateServerUpdateReadyEvent(result, ready("committed"));
205 const rollback = yield* Effect.flip(
206 validateServerUpdateReadyEvent(result, ready("rolled-back")),
207 );
208 expect(rollback.message).toBe("prepared-timeout");
209 }),
210 );
211
176212 it("applies every config category to the projected snapshot", () => {
177213 const snapshot = applyServerConfigProjection(Option.none(), {
178214 version: 1,
packages/client-runtime/src/state/server.ts+56-3
......@@ -3,6 +3,7 @@ import {
33 type ServerConfig,
44 type ServerConfigStreamEvent,
55 type ServerLifecycleWelcomePayload,
6 type ServerLifecycleStreamReadyEvent,
67 type ServerSelfUpdateProgressEvent,
78 type ServerSelfUpdateResult,
89 WS_METHODS,
......@@ -97,6 +98,57 @@ export class ServerUpdateProgressIncompleteError extends Schema.TaggedErrorClass
9798 }
9899}
99100
101export class ServerUpdateTerminalError extends Schema.TaggedErrorClass<ServerUpdateTerminalError>()(
102 "ServerUpdateTerminalError",
103 {
104 targetVersion: Schema.String,
105 status: Schema.Literals(["committed", "rolled-back", "failed"]),
106 reason: Schema.optional(Schema.String),
107 },
108) {
109 override get message(): string {
110 return this.reason ?? `The t3@${this.targetVersion} update ${this.status}.`;
111 }
112}
113
114// Covers the 120-second trial deadline and a final restart of the previous
115// version when the trial rolls back.
116const SERVER_UPDATE_RESUME_TIMEOUT = Duration.minutes(4);
117
118export function matchesServerUpdateReadyEvent(
119 result: ServerSelfUpdateResult,
120 event: ServerLifecycleStreamReadyEvent,
121): boolean {
122 return result.updateId === undefined
123 ? event.payload.environment.serverVersion === result.targetVersion
124 : event.payload.updateOutcome?.id === result.updateId;
125}
126
127export function validateServerUpdateReadyEvent(
128 result: ServerSelfUpdateResult,
129 event: ServerLifecycleStreamReadyEvent,
130): Effect.Effect<void, ServerUpdateTerminalError> {
131 if (result.updateId === undefined) return Effect.void;
132 const outcome = event.payload.updateOutcome;
133 if (
134 outcome?.id === result.updateId &&
135 outcome.status === "committed" &&
136 outcome.targetVersion === result.targetVersion &&
137 event.payload.environment.serverVersion === result.targetVersion
138 ) {
139 return Effect.void;
140 }
141 return Effect.fail(
142 new ServerUpdateTerminalError({
143 targetVersion: result.targetVersion,
144 status: outcome?.status ?? "failed",
145 reason:
146 outcome?.reason ??
147 "The service launcher resumed without committing the requested server version.",
148 }),
149 );
150}
151
100152export function serverUpdateStateForProgressEvent(
101153 fromVersion: string,
102154 targetVersion: string,
......@@ -554,11 +606,11 @@ export function createServerEnvironmentAtoms<R, E>(
554606 .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {}))
555607 .pipe(
556608 Stream.filter(
557 (event) =>
558 event.type === "ready" && event.payload.environment.serverVersion === targetVersion,
609 (event): event is ServerLifecycleStreamReadyEvent =>
610 event.type === "ready" && matchesServerUpdateReadyEvent(result, event),
559611 ),
560612 Stream.runHead,
561 Effect.timeoutOption(Duration.seconds(120)),
613 Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT),
562614 Effect.map(Option.flatten),
563615 );
564616 if (Option.isNone(resumed)) {
......@@ -567,6 +619,7 @@ export function createServerEnvironmentAtoms<R, E>(
567619 targetVersion,
568620 });
569621 }
622 yield* validateServerUpdateReadyEvent(result, resumed.value);
570623
571624 atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE);
572625 return result;
packages/contracts/src/environment.ts+3-3
......@@ -20,9 +20,9 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({
2020});
2121export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;
2222
23/** How a server can replace itself with another version when asked over RPC:
24 "boot-service" rewrites the systemd user unit and restarts it; "respawn"
25 installs the target version and respawns the foreground process. */
23/** How a server can replace itself with another version when asked over RPC.
24 New servers only advertise the stable launcher-backed "boot-service" path;
25 "respawn" remains decodable for compatibility with older servers. */
2626export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]);
2727export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;
2828
packages/contracts/src/server.ts+14
......@@ -521,9 +521,21 @@ export const ServerConfigStreamEvent = Schema.Union([
521521]);
522522export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
523523
524/** Terminal selection recorded by the service launcher for one update. */
525export const ServerSelfUpdateOutcome = Schema.Struct({
526 id: TrimmedNonEmptyString,
527 fromVersion: TrimmedNonEmptyString,
528 targetVersion: TrimmedNonEmptyString,
529 status: Schema.Literals(["committed", "rolled-back", "failed"]),
530 reason: Schema.optionalKey(TrimmedNonEmptyString),
531});
532export type ServerSelfUpdateOutcome = typeof ServerSelfUpdateOutcome.Type;
533
524534export const ServerLifecycleReadyPayload = Schema.Struct({
525535 at: IsoDateTime,
526536 environment: ExecutionEnvironmentDescriptor,
537 /** Present when this process resumed a launcher-managed update. */
538 updateOutcome: Schema.optionalKey(ServerSelfUpdateOutcome),
527539});
528540export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type;
529541
......@@ -594,6 +606,8 @@ export type ServerSelfUpdateInput = typeof ServerSelfUpdateInput.Type;
594606export const ServerSelfUpdateResult = Schema.Struct({
595607 targetVersion: TrimmedNonEmptyString,
596608 method: ServerSelfUpdateMethod,
609 /** Launcher-generated correlation ID. Absent when talking to older servers. */
610 updateId: Schema.optionalKey(TrimmedNonEmptyString),
597611});
598612export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type;
599613