From 58de11d7b7046a3b7539ccf2534bd2558300f962 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:30:47 +0000 Subject: [PATCH 01/11] test(server): reproduce #17225 launch agent program path changing per version These tests reproduce pingdotgg/t3code#17225: on macOS each service install at a new version writes runtime/versions//t3 into the plist, so TCC grants keyed to that path are lost on every update. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/bootService.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index fca51dcac1e09..b200d1ac217e1 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -775,6 +775,24 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + // regression: #17225 macOS privacy grants are keyed to the launch agent's program path + it.effect("keeps the launch agent program at one path across version updates", () => + Effect.gen(function* () { + const { service, makeService, fs } = yield* makeHarness("darwin"); + const plan = yield* service.install(); + const program = BootService.bootServiceProgramOf(yield* fs.readFileString(plan.unitPath)); + + yield* (yield* makeService(undefined, "1.2.4")).install(); + const updatedProgram = BootService.bootServiceProgramOf( + yield* fs.readFileString(plan.unitPath), + ); + + expect(updatedProgram).toBe(program); + expect(updatedProgram).not.toContain("/runtime/versions/"); + expect(yield* fs.exists(updatedProgram ?? "")).toBe(true); + }), + ); + // regression: #8173 keep provider CLIs available in the macOS service it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { From 46604ac5ba61b4cece6c554289cee2502ec95954 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:39:05 +0000 Subject: [PATCH 02/11] fix(server): macOS launch agent keeps one program path across updates macOS TCC keys a non-bundled launch agent's privacy grants to its program path, and the plist named runtime/versions//t3, so every update dropped Full Disk Access and other grants (pingdotgg/t3code#17225). On launchd, install now copies the installed version directory to a fixed runtime/launcher and points the plist there. The copy clones on APFS, is staged in launcher.next and swapped in by rename so a running launcher is never written into, and the previous copy is restored if the swap fails. Status reports a missing or stale copy as not current, and uninstall removes it. systemd output is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/bootService.test.ts | 59 ++++++++++++++- apps/server/src/cloud/bootService.ts | 89 ++++++++++++++++++++--- 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index b200d1ac217e1..79c998fd7ac91 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -468,7 +468,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.4", }); - expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); + if (platform === "darwin") { + const program = BootService.bootServiceProgramOf(yield* fs.readFileString(plan.unitPath)); + expect(program).toBe(path.join(baseDir, "runtime", "launcher", "t3")); + expect( + yield* fs.readFileString( + path.join(baseDir, "runtime", "launcher", ".install-complete"), + ), + ).toBe("1.2.4\n"); + } else { + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); + } expect( commands.filter( (command) => @@ -742,8 +752,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { - const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin"); + const { service, fs, baseDir, statePath, commands, timeouts } = yield* makeHarness("darwin"); const path = yield* Path.Path; + const launcherDir = path.join(baseDir, "runtime", "launcher"); const plan = yield* service.install(); expect( @@ -759,13 +770,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => { activeVersion: "1.2.3", }); expect(yield* fs.readFileString(plan.unitPath)).toContain( - ` ${runtime.entryPath}\n __service-launcher`, + ` ${path.join(launcherDir, "t3")}\n __service-launcher`, ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", }); + yield* fs.writeFileString(path.join(launcherDir, ".install-complete"), "1.2.2\n"); + expect((yield* service.status).current).toBe(false); + yield* service.install(); + expect((yield* service.status).current).toBe(true); expect(yield* service.uninstall).toBe(true); + expect(yield* fs.exists(launcherDir)).toBe(false); expect((yield* service.status).installed).toBe(false); expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); // launchd can take 90s to bootout; the runner's 60s default could let bootstrap race it. @@ -793,6 +809,43 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("swaps in a new launch agent runtime without writing into the running one", () => + Effect.gen(function* () { + const { service, makeService, fs, baseDir } = yield* makeHarness("darwin"); + const path = yield* Path.Path; + const launcherDir = path.join(baseDir, "runtime", "launcher"); + const versionEntry = (version: string) => + path.join(baseDir, "runtime", "versions", version, "t3"); + yield* service.install(); + const running = yield* fs.open(path.join(launcherDir, "t3"), { flag: "r" }); + + const newer = yield* makeService(undefined, "1.2.4"); + yield* fs.writeFileString(versionEntry("1.2.4"), "#!/bin/sh\n# 1.2.4\n"); + yield* newer.install({ start: false }); + + const buffer = new Uint8Array(64); + const read = yield* running.read(buffer); + expect(new TextDecoder().decode(buffer.subarray(0, Number(read)))).toBe("#!/bin/sh\n"); + expect(yield* fs.readFileString(path.join(launcherDir, "t3"))).toBe("#!/bin/sh\n# 1.2.4\n"); + expect((yield* fs.stat(path.join(launcherDir, "t3"))).mode & 0o111).toBe( + (yield* fs.stat(versionEntry("1.2.4"))).mode & 0o111, + ); + + const failingSwap = yield* makeService(undefined, "1.2.5").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + rename: (from, to) => + fs.rename(from, from === `${launcherDir}.next` ? path.join(to, "missing", "t3") : to), + }), + ); + expect((yield* Effect.flip(failingSwap.install({ start: false })))._tag).toBe( + "BootServiceInstallError", + ); + expect(yield* fs.readFileString(path.join(launcherDir, ".install-complete"))).toBe("1.2.4\n"); + expect(yield* fs.readFileString(path.join(launcherDir, "t3"))).toBe("#!/bin/sh\n# 1.2.4\n"); + }), + ); + // regression: #8173 keep provider CLIs available in the macOS service it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 15b3b16a7bc0f..6776357f25385 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1,4 +1,6 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's copy cannot ask for a clone, which costs no disk on APFS. +import * as NodeFSP from "node:fs/promises"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -20,6 +22,7 @@ import { pinnedRuntimeCommand, pinnedRuntimePaths, PinnedRuntimeInstallError, + type PinnedRuntimePaths, } from "./pinnedRuntime.ts"; import { SERVICE_LAUNCHER_PROTOCOL, @@ -201,6 +204,8 @@ export interface BootServiceManager { readonly handoff?: ReadonlyArray; readonly deactivate: ReadonlyArray; readonly finalize: ReadonlyArray; + /** The unit starts a copy of the runtime kept here instead of its versioned directory. */ + readonly launcherDir?: string; } function systemdManager(input: { @@ -282,6 +287,7 @@ function systemdManager(input: { function launchdManager(input: { readonly path: Path.Path; readonly homeDir: string; + readonly baseDir: string; readonly uid: number; readonly environmentPath: string; }): BootServiceManager { @@ -344,12 +350,15 @@ function launchdManager(input: { }, ], finalize: [], + // TCC keys the launch agent's privacy grants to its program path, so it must not change. + launcherDir: input.path.join(input.baseDir, "runtime", "launcher"), }; } function selectBootServiceManager(input: { readonly platform: NodeJS.Platform; readonly homeDir: string; + readonly baseDir: string; readonly uid: number | undefined; readonly path: Path.Path; readonly environmentPath: string; @@ -364,6 +373,7 @@ function selectBootServiceManager(input: { return launchdManager({ path: input.path, homeDir: input.homeDir, + baseDir: input.baseDir, uid: input.uid, environmentPath: input.environmentPath, }); @@ -560,6 +570,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const detectedManager = selectBootServiceManager({ platform, homeDir, + baseDir: input.baseDir, uid, path, environmentPath, @@ -569,6 +580,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); + const launcherDir = detectedManager?.launcherDir; + const launcherPaths: PinnedRuntimePaths = + launcherDir === undefined + ? runtimePaths + : { + versionDir: launcherDir, + entryPath: path.join(launcherDir, path.basename(runtimePaths.entryPath)), + sentinelPath: path.join(launcherDir, path.basename(runtimePaths.sentinelPath)), + }; const writeDurably = (filePath: string, contents: string) => Effect.scoped( Effect.gen(function* () { @@ -589,12 +609,48 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }), ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); const plan: BootServicePlan = { - program: [runtimePaths.entryPath, "__service-launcher"], + program: [launcherPaths.entryPath, "__service-launcher"], baseDir: input.baseDir, logPath, unitPath, }; + // A running launcher keeps executing its copy, and writing into a running signed + // Mach-O can kill it, so a new copy is staged beside it and swapped in by rename. + const publishLauncher = Effect.fn("cloud.boot_service.publish_launcher")(function* ( + directory: string, + ) { + const staging = `${directory}.next`; + const previous = `${directory}.prev`; + yield* fs.remove(staging, { recursive: true, force: true }); + yield* fs.remove(previous, { recursive: true, force: true }); + yield* Effect.tryPromise({ + try: () => + NodeFSP.cp(runtimePaths.versionDir, staging, { + recursive: true, + verbatimSymlinks: true, + mode: NodeFSP.constants.COPYFILE_FICLONE, + }), + catch: (cause) => new BootServiceInstallError({ cause }), + }); + const replacing = yield* fs.exists(directory); + if (replacing) yield* fs.rename(directory, previous); + yield* fs + .rename(staging, directory) + .pipe( + Effect.tapError(() => + replacing ? fs.rename(previous, directory).pipe(Effect.ignore) : Effect.void, + ), + ); + yield* fs.remove(previous, { recursive: true, force: true }).pipe(Effect.ignore); + }); + const removeLauncher = (directory: string) => + Effect.forEach( + [directory, `${directory}.next`, `${directory}.prev`], + (target) => fs.remove(target, { recursive: true, force: true }), + { discard: true }, + ); + const requireManager = Effect.suspend(() => detectedManager === undefined ? new BootServiceUnsupportedError({ platform }) @@ -848,6 +904,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { return yield* new BootServiceUpdatePendingError(); } } + if (manager.launcherDir !== undefined) yield* publishLauncher(manager.launcherDir); yield* writeDurably(unitPath, manager.render(plan)); if (start) { @@ -953,9 +1010,12 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ) return false; yield* runSteps(manager.deactivate); - yield* fs - .remove(unitPath) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + yield* fs.remove(unitPath).pipe( + Effect.andThen( + manager.launcherDir === undefined ? Effect.void : removeLauncher(manager.launcherDir), + ), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + ); yield* runSteps(manager.finalize); return true; }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); @@ -967,10 +1027,20 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!(yield* fs.exists(unitPath))) { return { supported: true, installed: false, current: false, unitPath, logPath }; } - const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ + const runtimeCurrent = (paths: PinnedRuntimePaths) => + Effect.all([ + fs.exists(paths.entryPath), + fs.readFileString(paths.sentinelPath).pipe(Effect.option), + ]).pipe( + Effect.map( + ([entryExists, sentinel]) => + entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.cliVersion, + ), + ); + const [unit, runtimeIsCurrent, launcherIsCurrent, stateText] = yield* Effect.all([ fs.readFileString(unitPath), - fs.exists(runtimePaths.entryPath), - fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), + runtimeCurrent(runtimePaths), + runtimeCurrent(launcherPaths), fs.readFileString(statePath).pipe(Effect.option), ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; @@ -994,9 +1064,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { current: problems.length === 0 && normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && - runtimeEntryExists && - Option.isSome(runtimeSentinel) && - runtimeSentinel.value.trim() === input.cliVersion && + runtimeIsCurrent && + launcherIsCurrent && state?.activeVersion === input.cliVersion && state?.update?.status !== "pending", unitPath, From f15a4a6d321950a188e27bebb3a674385ce2691d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:57:07 +0000 Subject: [PATCH 03/11] fix(server): macOS launcher swap survives interruption and clones on APFS The launch agent's launcher publish could be interrupted between its two renames, leaving the plist pointing at a missing program. The renames and their restore now run uninterruptibly. The copy used fs.cp with COPYFILE_FICLONE, which libuv only honours on Linux, so macOS copied the whole runtime. It now runs `cp -c -pR` (APFS clone, plain copy where unsupported) and falls back to Effect's fs.copy. A failed copy no longer leaves launcher.next behind, and the mode test fixture is executable so lost permissions would fail it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/bootService.test.ts | 69 ++++++++++++++++++++++- apps/server/src/cloud/bootService.ts | 55 +++++++++++------- 2 files changed, 102 insertions(+), 22 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 79c998fd7ac91..4be81bea295cc 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -3,7 +3,9 @@ import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -79,7 +81,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const statePath = path.join(baseDir, "runtime", "service-state.json"); const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform); yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); - yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n"); + yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n", { mode: 0o755 }); yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); const commands: string[] = []; @@ -106,6 +108,13 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( invocations.push(input); timeouts.set(command, input.timeout); const failed = command === control.failCommand; + // GNU cp has no -c; leave a partial copy so the fallback has to clear it. + const copyRejected = input.command === "cp" && input.args[0] === "-c"; + if (copyRejected) { + const destination = input.args.at(-1) ?? ""; + yield* fs.makeDirectory(destination, { recursive: true }).pipe(Effect.orDie); + yield* fs.writeFileString(path.join(destination, "t3"), "partial").pipe(Effect.orDie); + } if (!failed && command === "loginctl enable-linger --no-ask-password 501") control.linger = "yes"; if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; @@ -133,6 +142,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( stderr: "", code: ChildProcessSpawner.ExitCode( failed || + copyRejected || (input.args[1] === "is-active" && !control.active) || (input.args[1] === "is-enabled" && !control.enabled) ? 1 @@ -155,7 +165,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( Effect.gen(function* () { const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform); yield* fs.makeDirectory(path.dirname(paths.entryPath), { recursive: true }); - yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n"); + yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n", { mode: 0o755 }); yield* fs.writeFileString(paths.sentinelPath, `${cliVersion}\n`); return yield* BootService.make({ baseDir: serviceBaseDir, @@ -772,6 +782,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(yield* fs.readFileString(plan.unitPath)).toContain( ` ${path.join(launcherDir, "t3")}\n __service-launcher`, ); + expect(commands).toContain( + `cp -c -pR ${path.join(baseDir, "runtime", "versions", "1.2.3")} ${launcherDir}.next`, + ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", @@ -843,6 +856,58 @@ it.layer(NodeServices.layer)("boot service install", (it) => { ); expect(yield* fs.readFileString(path.join(launcherDir, ".install-complete"))).toBe("1.2.4\n"); expect(yield* fs.readFileString(path.join(launcherDir, "t3"))).toBe("#!/bin/sh\n# 1.2.4\n"); + + const failingCopy = yield* makeService(undefined, "1.2.6").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + copy: (from, to) => + fs + .makeDirectory(to) + .pipe(Effect.andThen(fs.copy(path.join(from, "missing"), path.join(to, "missing")))), + }), + ); + expect((yield* Effect.flip(failingCopy.install({ start: false })))._tag).toBe( + "BootServiceInstallError", + ); + expect(yield* fs.exists(`${launcherDir}.next`)).toBe(false); + expect(yield* fs.readFileString(path.join(launcherDir, "t3"))).toBe("#!/bin/sh\n# 1.2.4\n"); + }), + ); + + it.effect("keeps a launch agent program in place when an update is interrupted mid-swap", () => + Effect.gen(function* () { + const { service, makeService, fs, baseDir } = yield* makeHarness("darwin"); + const path = yield* Path.Path; + const launcherDir = path.join(baseDir, "runtime", "launcher"); + const plan = yield* service.install(); + const movedAside = yield* Deferred.make(); + const proceed = yield* Deferred.make(); + const newer = yield* makeService(undefined, "1.2.4").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + rename: (from, to) => + from === launcherDir + ? fs + .rename(from, to) + .pipe( + Effect.andThen(Deferred.succeed(movedAside, undefined)), + Effect.andThen(Deferred.await(proceed)), + ) + : fs.rename(from, to), + }), + ); + + const installing = yield* Effect.forkChild(newer.install({ start: false })); + yield* Deferred.await(movedAside); + const interrupting = yield* Effect.forkChild(Fiber.interrupt(installing), { + startImmediately: true, + }); + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.join(interrupting); + + const program = BootService.bootServiceProgramOf(yield* fs.readFileString(plan.unitPath)); + expect(program).toBe(path.join(launcherDir, "t3")); + expect((yield* fs.stat(program ?? "")).mode & 0o111).toBe(0o111); }), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 6776357f25385..0569caedd5a57 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1,6 +1,4 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; -// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's copy cannot ask for a clone, which costs no disk on APFS. -import * as NodeFSP from "node:fs/promises"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -615,6 +613,22 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { unitPath, }; + // cp -c clones on APFS and copies all bytes where cloning is unsupported; fs.copy never clones. + const copyLauncher = (source: string, destination: string) => + runner + .run({ + command: "cp", + args: ["-c", "-pR", source, destination], + timeout: Duration.minutes(10), + }) + .pipe( + Effect.filterOrFail((result) => result.code === 0), + Effect.catch(() => + fs + .remove(destination, { recursive: true, force: true }) + .pipe(Effect.andThen(fs.copy(source, destination))), + ), + ); // A running launcher keeps executing its copy, and writing into a running signed // Mach-O can kill it, so a new copy is staged beside it and swapped in by rename. const publishLauncher = Effect.fn("cloud.boot_service.publish_launcher")(function* ( @@ -624,24 +638,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const previous = `${directory}.prev`; yield* fs.remove(staging, { recursive: true, force: true }); yield* fs.remove(previous, { recursive: true, force: true }); - yield* Effect.tryPromise({ - try: () => - NodeFSP.cp(runtimePaths.versionDir, staging, { - recursive: true, - verbatimSymlinks: true, - mode: NodeFSP.constants.COPYFILE_FICLONE, - }), - catch: (cause) => new BootServiceInstallError({ cause }), - }); - const replacing = yield* fs.exists(directory); - if (replacing) yield* fs.rename(directory, previous); - yield* fs - .rename(staging, directory) - .pipe( - Effect.tapError(() => - replacing ? fs.rename(previous, directory).pipe(Effect.ignore) : Effect.void, - ), - ); + yield* copyLauncher(runtimePaths.versionDir, staging).pipe( + Effect.onError(() => + fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + // Interrupted between the renames, the unit would point at a missing program. + yield* Effect.uninterruptible( + Effect.gen(function* () { + const replacing = yield* fs.exists(directory); + if (replacing) yield* fs.rename(directory, previous); + yield* fs + .rename(staging, directory) + .pipe( + Effect.tapError(() => + replacing ? fs.rename(previous, directory).pipe(Effect.ignore) : Effect.void, + ), + ); + }), + ); yield* fs.remove(previous, { recursive: true, force: true }).pipe(Effect.ignore); }); const removeLauncher = (directory: string) => From 4fabb88c72b0ae46bcd2c0cb07ad62e7329f88c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 15:18:47 +0000 Subject: [PATCH 04/11] fix(server): overlapping macOS installs keep a complete launcher Every install staged into the shared launcher.next and launcher.prev and deleted both first, so a second install could wipe the first one's staged copy mid-swap and leave launcher/ without t3. Each publish now stages in its own launcher.next-* temp directory, never deletes another's, and cleans up only its own; uninstall removes launcher and any launcher.next-* leftovers. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/bootService.test.ts | 86 +++++++++++++++++++++-- apps/server/src/cloud/bootService.ts | 62 +++++++++------- 2 files changed, 116 insertions(+), 32 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 4be81bea295cc..dbb28a6b56810 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -782,9 +782,13 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(yield* fs.readFileString(plan.unitPath)).toContain( ` ${path.join(launcherDir, "t3")}\n __service-launcher`, ); - expect(commands).toContain( - `cp -c -pR ${path.join(baseDir, "runtime", "versions", "1.2.3")} ${launcherDir}.next`, - ); + expect( + commands.some((command) => + command.startsWith( + `cp -c -pR ${path.join(baseDir, "runtime", "versions", "1.2.3")} ${launcherDir}.next-`, + ), + ), + ).toBe(true); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", @@ -848,7 +852,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.provideService(FileSystem.FileSystem, { ...fs, rename: (from, to) => - fs.rename(from, from === `${launcherDir}.next` ? path.join(to, "missing", "t3") : to), + fs.rename( + from, + from !== launcherDir && path.basename(from) === "launcher" + ? path.join(to, "missing", "t3") + : to, + ), }), ); expect((yield* Effect.flip(failingSwap.install({ start: false })))._tag).toBe( @@ -869,7 +878,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* Effect.flip(failingCopy.install({ start: false })))._tag).toBe( "BootServiceInstallError", ); - expect(yield* fs.exists(`${launcherDir}.next`)).toBe(false); + expect(yield* fs.readDirectory(path.join(baseDir, "runtime"))).not.toContainEqual( + expect.stringMatching(/^launcher\./), + ); expect(yield* fs.readFileString(path.join(launcherDir, "t3"))).toBe("#!/bin/sh\n# 1.2.4\n"); }), ); @@ -911,6 +922,71 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("leaves a complete launch agent runtime when two updates overlap", () => + Effect.gen(function* () { + const { service, makeService, fs, baseDir } = yield* makeHarness("darwin"); + const path = yield* Path.Path; + const launcherDir = path.join(baseDir, "runtime", "launcher"); + const plan = yield* service.install(); + const firstCopied = yield* Deferred.make(); + const resumeFirst = yield* Deferred.make(); + const secondPartlyCopied = yield* Deferred.make(); + const resumeSecond = yield* Deferred.make(); + const first = yield* makeService(undefined, "1.2.4").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + copy: (from, to) => + fs + .copy(from, to) + .pipe( + Effect.andThen(Deferred.succeed(firstCopied, undefined)), + Effect.andThen(Deferred.await(resumeFirst)), + ), + }), + ); + const second = yield* makeService(undefined, "1.2.5").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + copy: (from, to) => + fs + .makeDirectory(to, { recursive: true }) + .pipe( + Effect.andThen( + fs.copyFile( + path.join(from, ".install-complete"), + path.join(to, ".install-complete"), + ), + ), + Effect.andThen(Deferred.succeed(secondPartlyCopied, undefined)), + Effect.andThen(Deferred.await(resumeSecond)), + Effect.andThen(fs.copyFile(path.join(from, "t3"), path.join(to, "t3"))), + ), + }), + ); + const versionEntry = (version: string) => + path.join(baseDir, "runtime", "versions", version, "t3"); + yield* fs.writeFileString(versionEntry("1.2.4"), "#!/bin/sh\n# 1.2.4\n"); + yield* fs.writeFileString(versionEntry("1.2.5"), "#!/bin/sh\n# 1.2.5\n"); + + const firstInstall = yield* Effect.forkChild(first.install({ start: false })); + yield* Deferred.await(firstCopied); + const secondInstall = yield* Effect.forkChild(second.install({ start: false })); + yield* Deferred.await(secondPartlyCopied); + yield* Deferred.succeed(resumeFirst, undefined); + yield* Fiber.await(firstInstall); + yield* Deferred.succeed(resumeSecond, undefined); + yield* Fiber.await(secondInstall); + + const program = BootService.bootServiceProgramOf(yield* fs.readFileString(plan.unitPath)); + expect(program).toBe(path.join(launcherDir, "t3")); + expect(yield* fs.exists(program ?? "")).toBe(true); + expect(["#!/bin/sh\n# 1.2.4\n", "#!/bin/sh\n# 1.2.5\n"]).toContain( + yield* fs.readFileString(program ?? ""), + ); + expect((yield* fs.stat(program ?? "")).mode & 0o111).toBe(0o111); + }), + ); + // regression: #8173 keep provider CLIs available in the macOS service it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0569caedd5a57..814805b7ce3b4 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -634,37 +634,45 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const publishLauncher = Effect.fn("cloud.boot_service.publish_launcher")(function* ( directory: string, ) { - const staging = `${directory}.next`; - const previous = `${directory}.prev`; - yield* fs.remove(staging, { recursive: true, force: true }); - yield* fs.remove(previous, { recursive: true, force: true }); - yield* copyLauncher(runtimePaths.versionDir, staging).pipe( - Effect.onError(() => - fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore), - ), - ); - // Interrupted between the renames, the unit would point at a missing program. - yield* Effect.uninterruptible( - Effect.gen(function* () { - const replacing = yield* fs.exists(directory); - if (replacing) yield* fs.rename(directory, previous); - yield* fs - .rename(staging, directory) - .pipe( - Effect.tapError(() => - replacing ? fs.rename(previous, directory).pipe(Effect.ignore) : Effect.void, - ), - ); - }), + // Staging is per publish and rename onto a non-empty launcher fails, so overlaps publish only complete copies. + const staging = yield* fs.makeTempDirectory({ + directory: path.dirname(directory), + prefix: `${path.basename(directory)}.next-`, + }); + const next = path.join(staging, "launcher"); + const previous = path.join(staging, "previous"); + yield* Effect.gen(function* () { + yield* copyLauncher(runtimePaths.versionDir, next); + // Interrupted between the renames, the unit would point at a missing program. + yield* Effect.uninterruptible( + Effect.gen(function* () { + const replacing = yield* fs.exists(directory); + if (replacing) yield* fs.rename(directory, previous); + yield* fs + .rename(next, directory) + .pipe( + Effect.tapError(() => + replacing ? fs.rename(previous, directory).pipe(Effect.ignore) : Effect.void, + ), + ); + }), + ); + }).pipe( + Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), ); - yield* fs.remove(previous, { recursive: true, force: true }).pipe(Effect.ignore); }); - const removeLauncher = (directory: string) => - Effect.forEach( - [directory, `${directory}.next`, `${directory}.prev`], - (target) => fs.remove(target, { recursive: true, force: true }), + const removeLauncher = Effect.fn("cloud.boot_service.remove_launcher")(function* ( + directory: string, + ) { + const name = path.basename(directory); + const parent = path.dirname(directory); + const entries = yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + entries.filter((entry) => entry === name || entry.startsWith(`${name}.next-`)), + (entry) => fs.remove(path.join(parent, entry), { recursive: true, force: true }), { discard: true }, ); + }); const requireManager = Effect.suspend(() => detectedManager === undefined From f4af3791d6f3eb10ac1c1c91f167542aaf9a3a24 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 15:19:21 +0000 Subject: [PATCH 05/11] docs: macOS Full Disk Access for the service carries over updates Co-Authored-By: Claude Opus 5.5 --- docs/user/background-service.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/user/background-service.md b/docs/user/background-service.md index be4ad66ff3ab4..039b0b117d4fe 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -125,7 +125,8 @@ On macOS, check **System Settings → General → Login Items** if the service n longer starts at login. If agent work cannot access Desktop, Documents, or Downloads, it may need Full Disk Access for the `t3` executable listed in `ProgramArguments` in -`~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. +`~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. That path stays the +same across updates, so the grant carries over. For failures after signing in to T3 Connect, see [connection troubleshooting](./t3-connect-troubleshooting.md). From 7a90b56c54a924d63d91c40e3eecd96d048c1da4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 15:53:49 +0000 Subject: [PATCH 06/11] docs: note the one-time macOS re-grant when the service moves to its fixed path Co-Authored-By: Claude Opus 5.5 --- docs/user/background-service.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 039b0b117d4fe..2ed9a642b63db 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -126,7 +126,9 @@ longer starts at login. If agent work cannot access Desktop, Documents, or Downloads, it may need Full Disk Access for the `t3` executable listed in `ProgramArguments` in `~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. That path stays the -same across updates, so the grant carries over. +same across updates, so the grant carries over. A service installed by an +older version moves to this path on its next update and needs the grant once +more. For failures after signing in to T3 Connect, see [connection troubleshooting](./t3-connect-troubleshooting.md). From d68d95ad1deec7ee725262b59df2be6b494a32e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:14:21 +0000 Subject: [PATCH 07/11] test(server): hoist orDie in the boot service runner fake and drop a mock-call assertion AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cloud/bootService.test.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 9f09ff8f09a9f..5f084e860a354 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -141,8 +141,8 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const copyRejected = input.command === "cp" && input.args[0] === "-c"; if (copyRejected) { const destination = input.args.at(-1) ?? ""; - yield* fs.makeDirectory(destination, { recursive: true }).pipe(Effect.orDie); - yield* fs.writeFileString(path.join(destination, "t3"), "partial").pipe(Effect.orDie); + yield* fs.makeDirectory(destination, { recursive: true }); + yield* fs.writeFileString(path.join(destination, "t3"), "partial"); } if (!failed && command === "loginctl enable-linger --no-ask-password 501") control.linger = "yes"; @@ -155,7 +155,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( (command === "systemctl --user stop t3code.service" || command.startsWith("launchctl bootout --wait ")) ) { - yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); + yield* fs.writeFileString(statePath, control.stateAfterStop); } return { stdout: @@ -194,7 +194,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( stdoutInvalidUtf8: false, stderrInvalidUtf8: false, }; - }), + }, Effect.orDie), }); const makeService = ( environmentPath: string | undefined = installerPath, @@ -879,13 +879,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(yield* fs.readFileString(plan.unitPath)).toContain( ` ${path.join(launcherDir, "t3")}\n __service-launcher`, ); - expect( - commands.some((command) => - command.startsWith( - `cp -c -pR ${path.join(baseDir, "runtime", "versions", "1.2.3")} ${launcherDir}.next-`, - ), - ), - ).toBe(true); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", From 5a7ddadd30649da5cee8477f905e9d67af6010ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 02:16:06 +0000 Subject: [PATCH 08/11] test(server): fold the duplicate launch agent path assertion into one expectation --- apps/server/src/cloud/bootService.test.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 5f084e860a354..43079697ae082 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -570,17 +570,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.4", }); - if (platform === "darwin") { - const program = BootService.bootServiceProgramOf(yield* fs.readFileString(plan.unitPath)); - expect(program).toBe(path.join(baseDir, "runtime", "launcher", "t3")); - expect( - yield* fs.readFileString( - path.join(baseDir, "runtime", "launcher", ".install-complete"), - ), - ).toBe("1.2.4\n"); - } else { - expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); - } + expect(yield* fs.readFileString(plan.unitPath)).toContain( + platform === "darwin" ? "runtime/launcher/t3" : "versions/1.2.4/t3", + ); expect( commands.filter( (command) => From 83c66af35a1f7539992c032417d7604d14d68126 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 02:29:32 +0000 Subject: [PATCH 09/11] fix(server): swap only the macOS launcher executable so its path never goes missing Publishing the launcher used to move the installed launcher directory aside before renaming its replacement in. Process death between the two renames left the launch agent plist pointing at a missing executable until another install. The launcher needs only its executable, so publication now stages the copy and the version sentinel inside the launcher directory and renames each file over its predecessor. The program path always holds a complete old or new binary, and a running launcher keeps its old inode. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cloud/bootService.test.ts | 116 ++++------------------ apps/server/src/cloud/bootService.ts | 70 ++++--------- 2 files changed, 38 insertions(+), 148 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 43079697ae082..86f855b1e2ab2 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -3,9 +3,7 @@ import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Duration from "effect/Duration"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -139,11 +137,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const failed = command === control.failCommand; // GNU cp has no -c; leave a partial copy so the fallback has to clear it. const copyRejected = input.command === "cp" && input.args[0] === "-c"; - if (copyRejected) { - const destination = input.args.at(-1) ?? ""; - yield* fs.makeDirectory(destination, { recursive: true }); - yield* fs.writeFileString(path.join(destination, "t3"), "partial"); - } + if (copyRejected) yield* fs.writeFileString(input.args.at(-1) ?? "", "partial"); if (!failed && command === "loginctl enable-linger --no-ask-password 501") control.linger = "yes"; if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; @@ -934,12 +928,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.provideService(FileSystem.FileSystem, { ...fs, rename: (from, to) => - fs.rename( - from, - from !== launcherDir && path.basename(from) === "launcher" - ? path.join(to, "missing", "t3") - : to, - ), + to === path.join(launcherDir, "t3") + ? fs.rename(path.join(from, "missing"), to) + : fs.rename(from, to), }), ); expect((yield* Effect.flip(failingSwap.install({ start: false })))._tag).toBe( @@ -951,121 +942,50 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const failingCopy = yield* makeService(undefined, "1.2.6").pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, - copy: (from, to) => - fs - .makeDirectory(to) - .pipe(Effect.andThen(fs.copy(path.join(from, "missing"), path.join(to, "missing")))), + copyFile: (from, to) => fs.copyFile(path.join(from, "missing"), to), }), ); expect((yield* Effect.flip(failingCopy.install({ start: false })))._tag).toBe( "BootServiceInstallError", ); - expect(yield* fs.readDirectory(path.join(baseDir, "runtime"))).not.toContainEqual( - expect.stringMatching(/^launcher\./), + expect(yield* fs.readDirectory(launcherDir)).toEqual( + expect.arrayContaining(["t3", ".install-complete"]), ); + expect(yield* fs.readDirectory(launcherDir)).toHaveLength(2); expect(yield* fs.readFileString(path.join(launcherDir, "t3"))).toBe("#!/bin/sh\n# 1.2.4\n"); }), ); - it.effect("keeps a launch agent program in place when an update is interrupted mid-swap", () => + it.effect("keeps a launch agent program in place when an update dies mid-publish", () => Effect.gen(function* () { const { service, makeService, fs, baseDir } = yield* makeHarness("darwin"); const path = yield* Path.Path; const launcherDir = path.join(baseDir, "runtime", "launcher"); const plan = yield* service.install(); - const movedAside = yield* Deferred.make(); - const proceed = yield* Deferred.make(); const newer = yield* makeService(undefined, "1.2.4").pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, rename: (from, to) => - from === launcherDir - ? fs - .rename(from, to) - .pipe( - Effect.andThen(Deferred.succeed(movedAside, undefined)), - Effect.andThen(Deferred.await(proceed)), - ) - : fs.rename(from, to), - }), - ); - - const installing = yield* Effect.forkChild(newer.install({ start: false })); - yield* Deferred.await(movedAside); - const interrupting = yield* Effect.forkChild(Fiber.interrupt(installing), { - startImmediately: true, - }); - yield* Deferred.succeed(proceed, undefined); - yield* Fiber.join(interrupting); - - const program = BootService.bootServiceProgramOf(yield* fs.readFileString(plan.unitPath)); - expect(program).toBe(path.join(launcherDir, "t3")); - expect((yield* fs.stat(program ?? "")).mode & 0o111).toBe(0o111); - }), - ); - - it.effect("leaves a complete launch agent runtime when two updates overlap", () => - Effect.gen(function* () { - const { service, makeService, fs, baseDir } = yield* makeHarness("darwin"); - const path = yield* Path.Path; - const launcherDir = path.join(baseDir, "runtime", "launcher"); - const plan = yield* service.install(); - const firstCopied = yield* Deferred.make(); - const resumeFirst = yield* Deferred.make(); - const secondPartlyCopied = yield* Deferred.make(); - const resumeSecond = yield* Deferred.make(); - const first = yield* makeService(undefined, "1.2.4").pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - copy: (from, to) => fs - .copy(from, to) - .pipe( - Effect.andThen(Deferred.succeed(firstCopied, undefined)), - Effect.andThen(Deferred.await(resumeFirst)), - ), - }), - ); - const second = yield* makeService(undefined, "1.2.5").pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - copy: (from, to) => - fs - .makeDirectory(to, { recursive: true }) + .rename(from, to) .pipe( Effect.andThen( - fs.copyFile( - path.join(from, ".install-complete"), - path.join(to, ".install-complete"), - ), + from === launcherDir || to === path.join(launcherDir, "t3") + ? Effect.die("process died") + : Effect.void, ), - Effect.andThen(Deferred.succeed(secondPartlyCopied, undefined)), - Effect.andThen(Deferred.await(resumeSecond)), - Effect.andThen(fs.copyFile(path.join(from, "t3"), path.join(to, "t3"))), ), }), ); - const versionEntry = (version: string) => - path.join(baseDir, "runtime", "versions", version, "t3"); - yield* fs.writeFileString(versionEntry("1.2.4"), "#!/bin/sh\n# 1.2.4\n"); - yield* fs.writeFileString(versionEntry("1.2.5"), "#!/bin/sh\n# 1.2.5\n"); - - const firstInstall = yield* Effect.forkChild(first.install({ start: false })); - yield* Deferred.await(firstCopied); - const secondInstall = yield* Effect.forkChild(second.install({ start: false })); - yield* Deferred.await(secondPartlyCopied); - yield* Deferred.succeed(resumeFirst, undefined); - yield* Fiber.await(firstInstall); - yield* Deferred.succeed(resumeSecond, undefined); - yield* Fiber.await(secondInstall); + yield* Effect.exit(newer.install({ start: false })); const program = BootService.bootServiceProgramOf(yield* fs.readFileString(plan.unitPath)); expect(program).toBe(path.join(launcherDir, "t3")); - expect(yield* fs.exists(program ?? "")).toBe(true); - expect(["#!/bin/sh\n# 1.2.4\n", "#!/bin/sh\n# 1.2.5\n"]).toContain( - yield* fs.readFileString(program ?? ""), - ); expect((yield* fs.stat(program ?? "")).mode & 0o111).toBe(0o111); + const current = yield* makeService(undefined, "1.2.4"); + expect((yield* current.status).current).toBe(false); + yield* current.install(); + expect((yield* current.status).current).toBe(true); }), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 6a9b978f08e1f..0c59abcef37d8 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -706,66 +706,34 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { unitPath, }; - // cp -c clones on APFS and copies all bytes where cloning is unsupported; fs.copy never clones. + // cp -c clones on APFS and copies all bytes where cloning is unsupported; copyFile never clones. const copyLauncher = (source: string, destination: string) => runner - .run({ - command: "cp", - args: ["-c", "-pR", source, destination], - timeout: Duration.minutes(10), - }) + .run({ command: "cp", args: ["-c", "-p", source, destination], timeout: Duration.minutes(1) }) .pipe( Effect.filterOrFail((result) => result.code === 0), - Effect.catch(() => - fs - .remove(destination, { recursive: true, force: true }) - .pipe(Effect.andThen(fs.copy(source, destination))), - ), + Effect.catch(() => fs.copyFile(source, destination)), ); - // A running launcher keeps executing its copy, and writing into a running signed - // Mach-O can kill it, so a new copy is staged beside it and swapped in by rename. - const publishLauncher = Effect.fn("cloud.boot_service.publish_launcher")(function* ( - directory: string, - ) { - // Staging is per publish and rename onto a non-empty launcher fails, so overlaps publish only complete copies. + // `__service-launcher` loads only Node built-ins, so the executable alone is the launcher. + // Each file is renamed over its predecessor: the program path never goes missing, even if the + // process dies mid-publish, and a running launcher keeps its old inode instead of being written into. + const publishLauncher = Effect.fn("cloud.boot_service.publish_launcher")(function* () { + yield* fs.makeDirectory(launcherPaths.versionDir, { recursive: true }); const staging = yield* fs.makeTempDirectory({ - directory: path.dirname(directory), - prefix: `${path.basename(directory)}.next-`, + directory: launcherPaths.versionDir, + prefix: ".staging-", }); - const next = path.join(staging, "launcher"); - const previous = path.join(staging, "previous"); yield* Effect.gen(function* () { - yield* copyLauncher(runtimePaths.versionDir, next); - // Interrupted between the renames, the unit would point at a missing program. - yield* Effect.uninterruptible( - Effect.gen(function* () { - const replacing = yield* fs.exists(directory); - if (replacing) yield* fs.rename(directory, previous); - yield* fs - .rename(next, directory) - .pipe( - Effect.tapError(() => - replacing ? fs.rename(previous, directory).pipe(Effect.ignore) : Effect.void, - ), - ); - }), - ); + const entry = path.join(staging, path.basename(launcherPaths.entryPath)); + const sentinel = path.join(staging, path.basename(launcherPaths.sentinelPath)); + yield* copyLauncher(runtimePaths.entryPath, entry); + yield* fs.writeFileString(sentinel, `${input.cliVersion}\n`); + yield* fs.rename(entry, launcherPaths.entryPath); + yield* fs.rename(sentinel, launcherPaths.sentinelPath); }).pipe( Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), ); }); - const removeLauncher = Effect.fn("cloud.boot_service.remove_launcher")(function* ( - directory: string, - ) { - const name = path.basename(directory); - const parent = path.dirname(directory); - const entries = yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => [])); - yield* Effect.forEach( - entries.filter((entry) => entry === name || entry.startsWith(`${name}.next-`)), - (entry) => fs.remove(path.join(parent, entry), { recursive: true, force: true }), - { discard: true }, - ); - }); const requireManager = Effect.suspend(() => detectedManager === undefined @@ -1029,7 +997,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { return yield* new BootServiceUpdatePendingError(); } } - if (manager.launcherDir !== undefined) yield* publishLauncher(manager.launcherDir); + if (manager.launcherDir !== undefined) yield* publishLauncher(); yield* writeDurably(unitPath, manager.render(plan)); if (start) { @@ -1137,7 +1105,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* runSteps(manager.deactivate); yield* fs.remove(unitPath).pipe( Effect.andThen( - manager.launcherDir === undefined ? Effect.void : removeLauncher(manager.launcherDir), + manager.launcherDir === undefined + ? Effect.void + : fs.remove(manager.launcherDir, { recursive: true, force: true }), ), Effect.mapError((cause) => new BootServiceInstallError({ cause })), ); From f334e1d841c7f1bfeee4f9855a4ac73d963bd4ab Mon Sep 17 00:00:00 2001 From: Frank Mayer Date: Wed, 11 Nov 2026 18:53:38 +0100 Subject: [PATCH 10/11] fix(server): hold the unit lease through launcher publish and removal Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/bootService.ts | 23 +++++++++++++---------- apps/server/src/cloud/stableLauncher.ts | 23 ++++++++++++++--------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 44636497ede05..4e48ddf56985a 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -962,7 +962,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { try: () => publishStableLauncher(input.baseDir, runtimePaths.entryPath, input.cliVersion), catch: (cause) => new BootServiceInstallError({ cause }), - }); + }).pipe(Effect.uninterruptible); } yield* writeDurably(unitPath, manager.render(plan)); }), @@ -1083,16 +1083,19 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ) return false; yield* runSteps(manager.deactivate); - yield* fs.remove(unitPath).pipe( - Effect.andThen( - manager.kind === "launchd" - ? fs.remove(stableLauncherPaths(input.baseDir).directory, { - recursive: true, - force: true, - }) - : Effect.void, + yield* withSelectionLock( + unitPath, + fs.remove(unitPath).pipe( + Effect.andThen( + manager.kind === "launchd" + ? fs.remove(stableLauncherPaths(input.baseDir).directory, { + recursive: true, + force: true, + }) + : Effect.void, + ), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), ), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), ); yield* runSteps(manager.finalize); return true; diff --git a/apps/server/src/cloud/stableLauncher.ts b/apps/server/src/cloud/stableLauncher.ts index f6210a7f7c995..7fde72b134226 100644 --- a/apps/server/src/cloud/stableLauncher.ts +++ b/apps/server/src/cloud/stableLauncher.ts @@ -41,16 +41,21 @@ export async function publishStableLauncher( const stagedEntry = NodePath.join(paths.directory, ".t3.staged"); const stagedSentinel = NodePath.join(paths.directory, ".install-complete.staged"); await NodeFSP.mkdir(paths.directory, { recursive: true }); - await NodeFSP.rm(stagedEntry, { force: true }); - if (!(await cloneFile(sourceEntryPath, stagedEntry))) { - await NodeFSP.copyFile(sourceEntryPath, stagedEntry); + try { + await NodeFSP.rm(stagedEntry, { force: true }); + if (!(await cloneFile(sourceEntryPath, stagedEntry))) { + await NodeFSP.copyFile(sourceEntryPath, stagedEntry); + } + await NodeFSP.writeFile(stagedSentinel, `${version}\n`); + await syncPath(stagedEntry); + await syncPath(stagedSentinel); + await NodeFSP.rename(stagedEntry, paths.entryPath); + await NodeFSP.rename(stagedSentinel, paths.sentinelPath); + await syncPath(paths.directory); + } finally { + await NodeFSP.rm(stagedEntry, { force: true }); + await NodeFSP.rm(stagedSentinel, { force: true }); } - await NodeFSP.writeFile(stagedSentinel, `${version}\n`); - await syncPath(stagedEntry); - await syncPath(stagedSentinel); - await NodeFSP.rename(stagedEntry, paths.entryPath); - await NodeFSP.rename(stagedSentinel, paths.sentinelPath); - await syncPath(paths.directory); } // The unit is written durably right after this, so the program it names must be too. From 077930df32a46b82b4e15e7d7db8665a15c6bd4c Mon Sep 17 00:00:00 2001 From: Frank Mayer Date: Wed, 11 Nov 2026 19:05:38 +0100 Subject: [PATCH 11/11] fix(server): finish uninstall removal before releasing the unit lease Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/bootService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 4e48ddf56985a..21ed27534b934 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1095,6 +1095,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { : Effect.void, ), Effect.mapError((cause) => new BootServiceInstallError({ cause })), + Effect.uninterruptible, ), ); yield* runSteps(manager.finalize);