From be575646f0be360ab17f7907cb20096b0a2bcfde Mon Sep 17 00:00:00 2001 From: "Nick T." <73005126+tiliakoos@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:59:39 -0400 Subject: [PATCH 1/4] fix(server): t3 update and service restart no longer fail on macOS 15 macOS 15 rejects `launchctl bootout --wait`, so the old job stayed loaded and bootstrap failed with EIO. Stop with plain bootout and wait on `launchctl print` until launchd has removed the job, within the 120 s stop timeout. Fixes #11995 --- apps/server/src/cloud/bootService.test.ts | 111 +++++++++++++++++++--- apps/server/src/cloud/bootService.ts | 78 +++++++++++---- 2 files changed, 162 insertions(+), 27 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index d3a8af2320c4f..72f9eaf5be738 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -4,11 +4,14 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Duration from "effect/Duration"; 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"; +import * as Queue from "effect/Queue"; import { HttpClient } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; import * as ProcessRunner from "../processRunner.ts"; import * as BootService from "./bootService.ts"; @@ -91,11 +94,15 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( linger: string; enabled: boolean; active: boolean; + drainPolls: number; + onDrainPoll: Effect.Effect; } = { failCommand: undefined, linger: "yes", enabled: true, active: true, + drainPolls: 0, + onDrainPoll: Effect.void, }; const runner = ProcessRunner.ProcessRunner.of({ run: Effect.fn("test.run_boot_service_command")(function* ( @@ -112,10 +119,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( if (!failed && command === "systemctl --user restart t3code.service") control.active = true; // A stop that fails (a timeout, say) has still stopped the service. if (command === "systemctl --user stop t3code.service") control.active = false; + // On macOS `active` means loaded. A booted-out job still shows in + // `print` for `drainPolls` more calls while it drains. + if (!failed && command.startsWith("launchctl bootstrap ")) control.active = true; + if (!failed && command.startsWith("launchctl bootout ")) control.active = false; + const launchdPrint = !failed && command.startsWith("launchctl print "); + const draining = launchdPrint && !control.active && control.drainPolls > 0; + if (draining) { + control.drainPolls -= 1; + yield* control.onDrainPoll; + } if ( control.stateAfterStop !== undefined && (command === "systemctl --user stop t3code.service" || - command.startsWith("launchctl bootout --wait ")) + command.startsWith("launchctl bootout ")) ) { yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); } @@ -132,11 +149,15 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( : "", stderr: "", code: ChildProcessSpawner.ExitCode( - failed || - (input.args[1] === "is-active" && !control.active) || - (input.args[1] === "is-enabled" && !control.enabled) - ? 1 - : 0, + launchdPrint + ? control.active || draining + ? 0 + : 113 + : failed || + (input.args[1] === "is-active" && !control.active) || + (input.args[1] === "is-enabled" && !control.enabled) + ? 1 + : 0, ), timedOut: false, stdoutTruncated: false, @@ -404,7 +425,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => { platform === "linux" ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"] : [ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + "launchctl bootout gui/501/com.t3tools.t3code.service", + "launchctl print gui/501/com.t3tools.t3code.service", `launchctl bootstrap gui/501 ${plan.unitPath}`, ], ); @@ -774,9 +796,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); - // A bootout can block up to the plist's 90s ExitTimeOut; the runner's - // 60s default would cancel it and let bootstrap race a loaded job. - expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual( + // A bootout can block up to the plist's 90s ExitTimeOut, which the + // runner's 60s default would cut short. + expect(timeouts.get("launchctl bootout gui/501/com.t3tools.t3code.service")).toEqual( Duration.seconds(120), ); }), @@ -838,13 +860,80 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); yield* service.install(); - control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; + control.active = false; + control.failCommand = "launchctl bootout gui/501/com.t3tools.t3code.service"; yield* service.install(); expect((yield* service.status).current).toBe(true); }), ); + // regression: #11995 macOS 15 rejects `bootout --wait`, so the stop waits on `print` + it.effect("starts a booted-out launch agent again only once launchd has removed it", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); + const plan = yield* service.install(); + const polls = yield* Queue.unbounded(); + control.drainPolls = 2; + control.onDrainPoll = Queue.offer(polls, undefined).pipe(Effect.asVoid); + commands.length = 0; + + const reinstall = yield* service.install().pipe(Effect.forkChild); + yield* Queue.take(polls); + yield* TestClock.adjust("1 second"); + yield* Queue.take(polls); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(reinstall); + + const print = "launchctl print gui/501/com.t3tools.t3code.service"; + expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ + "launchctl bootout gui/501/com.t3tools.t3code.service", + print, + print, + print, + "launchctl enable gui/501/com.t3tools.t3code.service", + `launchctl bootstrap gui/501 ${plan.unitPath}`, + ]); + }), + ); + + it.effect("fails an install whose launch agent is still loaded after the stop timeout", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); + const plan = yield* service.install(); + const polls = yield* Queue.unbounded(); + control.drainPolls = Number.POSITIVE_INFINITY; + control.onDrainPoll = Queue.offer(polls, undefined).pipe(Effect.asVoid); + commands.length = 0; + + const reinstall = yield* service.install().pipe(Effect.flip, Effect.forkChild); + yield* Queue.take(polls); + yield* TestClock.adjust("120 seconds"); + + expect(yield* Fiber.join(reinstall)).toMatchObject({ + _tag: "BootServiceCommandError", + step: "waiting for the launch agent to stop", + }); + expect(commands).not.toContain(`launchctl bootstrap gui/501 ${plan.unitPath}`); + }), + ); + + it.effect("fails an install when launchd cannot say whether the agent stopped", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); + const plan = yield* service.install(); + control.failCommand = "launchctl print gui/501/com.t3tools.t3code.service"; + commands.length = 0; + + expect(yield* service.install().pipe(Effect.flip)).toMatchObject({ + _tag: "BootServiceCommandError", + step: "waiting for the launch agent to stop", + exitCode: 1, + }); + expect(commands).not.toContain(`launchctl bootstrap gui/501 ${plan.unitPath}`); + }), + ); + it.effect("keeps failed launch agent installs pending until activation succeeds", () => Effect.gen(function* () { const { service, fs, statePath, commands, control } = yield* makeHarness("darwin"); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index c1be1fc2c5ef4..309434ccaf92e 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -10,6 +10,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { HttpClient } from "effect/http"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; @@ -200,13 +201,20 @@ export interface BootServiceStep { */ readonly optional?: boolean; readonly timeout?: Duration.Input; + /** + * Re-run once a second while the command exits 0, for up to + * STOP_STEP_TIMEOUT, until it exits with one of these codes. Any other exit + * fails. + */ + readonly untilExit?: ReadonlyArray; } /** - * Stop commands block until the service manager gives up: 90s by default for - * systemd's TimeoutStopSec, and ExitTimeOut=90 in the rendered plist. This - * must stay above both, or the runner cancels the stop mid-shutdown and the - * next step races a still-loaded service. + * Stops end when the service manager gives up: 90s by default for systemd's + * TimeoutStopSec, which `systemctl stop` blocks on, and ExitTimeOut=90 in the + * rendered plist, which the launchd stop polls for. This must stay above both, + * or the stop gives up mid-shutdown and the next step races a still-loaded + * service. */ const STOP_STEP_TIMEOUT = Duration.seconds(120); @@ -313,6 +321,16 @@ function launchdManager(input: { ); const domainTarget = `gui/${input.uid}`; const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; + // bootout returns while the job drains for up to ExitTimeOut, and a + // bootstrap during the drain fails EIO. `print` finds the job until launchd + // has removed it: 113 is no such service, 112 no such domain. The wait is + // strict, so a bootout that failed with the job still loaded fails here. + const waitForStop: BootServiceStep = { + step: "waiting for the launch agent to stop", + command: "launchctl", + args: ["print", serviceTarget], + untilExit: [112, 113], + }; // bootout/enable are optional: they fail on not-loaded states that are fine // to proceed from. The strict `bootstrap` runs last and is also the start: // loading a RunAtLoad/KeepAlive plist starts the job, so a separate kickstart @@ -325,18 +343,15 @@ function launchdManager(input: { homeDir: input.homeDir, environmentPath: input.environmentPath, }), - // Without --wait, bootout returns in milliseconds while the job drains - // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. - // --wait (present on modern macOS, absent from the man page) blocks until - // the job is removed from the domain; STOP_STEP_TIMEOUT outlives it. stop: [ { step: "stopping the installed launch agent", command: "launchctl", - args: ["bootout", "--wait", serviceTarget], + args: ["bootout", serviceTarget], optional: true, timeout: STOP_STEP_TIMEOUT, }, + waitForStop, ], activate: [ // A persisted `launchctl disable` override refuses bootstrap; clear it. @@ -365,10 +380,11 @@ function launchdManager(input: { { step: "stopping the service", command: "launchctl", - args: ["bootout", "--wait", serviceTarget], + args: ["bootout", serviceTarget], optional: true, timeout: STOP_STEP_TIMEOUT, }, + waitForStop, ], finalize: [], }; @@ -660,16 +676,46 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); }); + const waitForStep = Effect.fn("cloud.boot_service.wait_for_step")(function* ( + step: string, + command: string, + args: ReadonlyArray, + untilExit: ReadonlyArray, + ) { + return yield* runner.run({ command, args }).pipe( + Effect.repeat({ + schedule: Schedule.spaced(Duration.seconds(1)), + while: (result) => result.code === 0, + }), + Effect.timeout(STOP_STEP_TIMEOUT), + Effect.mapError((cause) => new BootServiceCommandError({ step, cause })), + Effect.filterOrFail( + (result) => untilExit.includes(Number(result.code)), + (result) => + new BootServiceCommandError({ + step, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + Effect.tapError(logFailure), + ); + }); + const runSteps = (steps: ReadonlyArray) => Effect.forEach( steps, (entry) => { - const run = runStep( - entry.step, - entry.command, - entry.args, - entry.timeout === undefined ? undefined : { timeout: entry.timeout }, - ); + const run = + entry.untilExit === undefined + ? runStep( + entry.step, + entry.command, + entry.args, + entry.timeout === undefined ? undefined : { timeout: entry.timeout }, + ) + : waitForStep(entry.step, entry.command, entry.args, entry.untilExit); return entry.optional === true ? run.pipe(Effect.ignore) : run.pipe(Effect.asVoid); }, { discard: true }, From 8415aee83396e8449504264ee1ca250831779312 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 13:49:19 +0000 Subject: [PATCH 2/4] refactor(server): share launchd shutdown command validation --- apps/server/src/cloud/bootService.test.ts | 7 ++- apps/server/src/cloud/bootService.ts | 67 +++++++---------------- 2 files changed, 25 insertions(+), 49 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index dc5699170e037..5229f172ab279 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -94,6 +94,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( linger: string; enabled: boolean; active: boolean; + missingExitCode: number; drainPolls: number; onDrainPoll: Effect.Effect; } = { @@ -101,6 +102,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( linger: "yes", enabled: true, active: true, + missingExitCode: 113, drainPolls: 0, onDrainPoll: Effect.void, }; @@ -152,7 +154,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( launchdPrint ? control.active || draining ? 0 - : 113 + : control.missingExitCode : failed || (input.args[1] === "is-active" && !control.active) || (input.args[1] === "is-enabled" && !control.enabled) @@ -849,11 +851,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("ignores a bootout for an agent that is not loaded", () => + it.effect.each([112, 113])("ignores a bootout for an agent missing with exit %s", (code) => Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); yield* service.install(); control.active = false; + control.missingExitCode = code; control.failCommand = "launchctl bootout gui/501/com.t3tools.t3code.service"; yield* service.install(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0632801b1a31a..36580dd6bf1b9 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -186,11 +186,7 @@ export interface BootServiceStep { /** Non-zero exit is logged and ignored. */ readonly optional?: boolean; readonly timeout?: Duration.Input; - /** - * Re-run once a second while the command exits 0, for up to - * STOP_STEP_TIMEOUT, until it exits with one of these codes. Any other exit - * fails. - */ + /** Poll successful probes until one of these exits, bounded by STOP_STEP_TIMEOUT. */ readonly untilExit?: ReadonlyArray; } @@ -300,10 +296,8 @@ function launchdManager(input: { ); const domainTarget = `gui/${input.uid}`; const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; - // bootout returns while the job drains for up to ExitTimeOut, and a - // bootstrap during the drain fails EIO. `print` finds the job until launchd - // has removed it: 113 is no such service, 112 no such domain. The wait is - // strict, so a bootout that failed with the job still loaded fails here. + // bootout returns before draining finishes; bootstrap races fail EIO until print reports + // no such service (113) or domain (112). Other failures must not permit bootstrap. const waitForStop: BootServiceStep = { step: "waiting for the launch agent to stop", command: "launchctl", @@ -631,39 +625,26 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { step: string, command: string, args: ReadonlyArray, - options?: { readonly timeout?: Duration.Input }, + options?: Pick, ) { - return yield* runner.run({ command, args, timeout: options?.timeout }).pipe( + const untilExit = options?.untilExit; + const commandRun = runner.run({ command, args, timeout: options?.timeout }); + const run = + untilExit === undefined + ? commandRun + : commandRun.pipe( + Effect.repeat({ + schedule: Schedule.spaced(Duration.seconds(1)), + while: (result) => result.code === 0, + }), + Effect.timeout(STOP_STEP_TIMEOUT), + Effect.withSpan("cloud.boot_service.wait_for_step"), + ); + return yield* run.pipe( Effect.mapError((cause) => new BootServiceCommandError({ step, cause })), Effect.filterOrFail( - (result) => result.code === 0, (result) => - new BootServiceCommandError({ - step, - exitCode: Number(result.code), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - Effect.tapError(logFailure), - ); - }); - - const waitForStep = Effect.fn("cloud.boot_service.wait_for_step")(function* ( - step: string, - command: string, - args: ReadonlyArray, - untilExit: ReadonlyArray, - ) { - return yield* runner.run({ command, args }).pipe( - Effect.repeat({ - schedule: Schedule.spaced(Duration.seconds(1)), - while: (result) => result.code === 0, - }), - Effect.timeout(STOP_STEP_TIMEOUT), - Effect.mapError((cause) => new BootServiceCommandError({ step, cause })), - Effect.filterOrFail( - (result) => untilExit.includes(Number(result.code)), + untilExit === undefined ? result.code === 0 : untilExit.includes(Number(result.code)), (result) => new BootServiceCommandError({ step, @@ -680,15 +661,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { Effect.forEach( steps, (entry) => { - const run = - entry.untilExit === undefined - ? runStep( - entry.step, - entry.command, - entry.args, - entry.timeout === undefined ? undefined : { timeout: entry.timeout }, - ) - : waitForStep(entry.step, entry.command, entry.args, entry.untilExit); + const run = runStep(entry.step, entry.command, entry.args, entry); return entry.optional === true ? run.pipe(Effect.ignore) : run.pipe(Effect.asVoid); }, { discard: true }, From 5b9d6d2bfb736feee1e2f6b41c38b8edc9de07b8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 13:55:51 +0000 Subject: [PATCH 3/4] fix(server): require launchd drain before maintenance and restoration --- apps/server/src/cloud/bootService.test.ts | 73 +++++++++++++++++------ apps/server/src/cloud/bootService.ts | 14 +++-- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 5229f172ab279..4f63376ea08bf 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -864,6 +864,32 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect( + "requires launchd shutdown confirmation for maintenance after a failed status probe", + () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.failCommand = "launchctl print gui/501/com.t3tools.t3code.service"; + let ran = false; + + const error = yield* service + .withStopped( + Effect.sync(() => { + ran = true; + }), + ) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "BootServiceCommandError", + step: "waiting for the launch agent to stop", + exitCode: 1, + }); + expect(ran).toBe(false); + }), + ); + // regression: #11995 macOS 15 rejects `bootout --wait`, so the stop waits on `print` it.effect("starts a booted-out launch agent again only once launchd has removed it", () => Effect.gen(function* () { @@ -893,25 +919,36 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("fails an install whose launch agent is still loaded after the stop timeout", () => - Effect.gen(function* () { - const { service, commands, control } = yield* makeHarness("darwin"); - const plan = yield* service.install(); - const polls = yield* Queue.unbounded(); - control.drainPolls = Number.POSITIVE_INFINITY; - control.onDrainPoll = Queue.offer(polls, undefined).pipe(Effect.asVoid); - commands.length = 0; - - const reinstall = yield* service.install().pipe(Effect.flip, Effect.forkChild); - yield* Queue.take(polls); - yield* TestClock.adjust("120 seconds"); + it.effect.each(["install", "maintenance"] as const)( + "fails %s while launchd is still draining after the stop timeout", + (operation) => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); + const plan = yield* service.install(); + const polls = yield* Queue.unbounded(); + control.drainPolls = Number.POSITIVE_INFINITY; + control.onDrainPoll = Queue.offer(polls, undefined).pipe(Effect.asVoid); + commands.length = 0; - expect(yield* Fiber.join(reinstall)).toMatchObject({ - _tag: "BootServiceCommandError", - step: "waiting for the launch agent to stop", - }); - expect(commands).not.toContain(`launchctl bootstrap gui/501 ${plan.unitPath}`); - }), + const run = + operation === "install" + ? service.install().pipe(Effect.asVoid) + : service.withStopped( + Effect.sync(() => { + commands.push("repair"); + }), + ); + const reinstall = yield* run.pipe(Effect.flip, Effect.forkChild); + yield* Queue.take(polls); + yield* TestClock.adjust("120 seconds"); + + expect(yield* Fiber.join(reinstall)).toMatchObject({ + _tag: "BootServiceCommandError", + step: "waiting for the launch agent to stop", + }); + expect(commands).not.toContain(`launchctl bootstrap gui/501 ${plan.unitPath}`); + expect(commands).not.toContain("repair"); + }), ); it.effect("fails an install when launchd cannot say whether the agent stopped", () => diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 36580dd6bf1b9..6c153cd87268a 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -950,13 +950,17 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); return yield* Effect.acquireUseRelease( // Require every stop before repair when restoring, so the server releases the database. - // Otherwise stops are best-effort; launchd rejects bootout of an unloaded job. - // A failed stop may still stop the service, so restoration runs either way. - runSteps(manager.stop.map((step) => ({ ...step, optional: !restore }))).pipe(Effect.exit), + // Launchd drain confirmation stays strict even when the initial status probe failed. + runSteps( + manager.stop.map((step) => ({ + ...step, + optional: !restore && step.untilExit === undefined, + })), + ).pipe(Effect.exit), (stopped): Effect.Effect => Exit.isSuccess(stopped) ? operation : Effect.failCause(stopped.cause), - (_, exit) => { - if (!restore) return Effect.void; + (stopped, exit) => { + if (!restore || (manager.kind === "launchd" && Exit.isFailure(stopped))) return Effect.void; // Start only: `activate` would also enable a unit the user disabled. // When the operation itself failed, its error is the one worth reporting. const start = runSteps(manager.restart); From e49fd29d93844f6f3fd0eefa2529220910b331f3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 14:09:33 +0000 Subject: [PATCH 4/4] fix(server): restore launchd only after confirming removal --- apps/server/src/cloud/bootService.test.ts | 35 +++++++++++++++++++++++ apps/server/src/cloud/bootService.ts | 24 ++++++++++------ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 4f63376ea08bf..c0cedd588fdc4 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -90,6 +90,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const timeouts = new Map(); const control: { failCommand: string | undefined; + failOnce?: boolean; stateAfterStop?: string; linger: string; enabled: boolean; @@ -115,6 +116,10 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( invocations.push(input); timeouts.set(command, input.timeout); const failed = command === control.failCommand; + if (failed && control.failOnce) { + control.failCommand = undefined; + control.failOnce = false; + } if (!failed && command === "loginctl enable-linger --no-ask-password 501") control.linger = "yes"; if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; @@ -890,6 +895,36 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect( + "restores launchd after a failed drain probe only when a final probe confirms removal", + () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); + const plan = yield* service.install(); + const polls = yield* Queue.unbounded(); + control.drainPolls = 1; + control.onDrainPoll = Effect.sync(() => { + control.failCommand = "launchctl print gui/501/com.t3tools.t3code.service"; + control.failOnce = true; + }).pipe(Effect.andThen(Queue.offer(polls, undefined)), Effect.asVoid); + commands.length = 0; + + const maintenance = yield* service + .withStopped(Effect.sync(() => commands.push("repair"))) + .pipe(Effect.flip, Effect.forkChild); + yield* Queue.take(polls); + yield* TestClock.adjust("1 second"); + + expect(yield* Fiber.join(maintenance)).toMatchObject({ + _tag: "BootServiceCommandError", + step: "waiting for the launch agent to stop", + }); + expect(commands).not.toContain("repair"); + expect(commands.at(-2)).toBe("launchctl print gui/501/com.t3tools.t3code.service"); + expect(commands.at(-1)).toBe(`launchctl bootstrap gui/501 ${plan.unitPath}`); + }), + ); + // regression: #11995 macOS 15 rejects `bootout --wait`, so the stop waits on `print` it.effect("starts a booted-out launch agent again only once launchd has removed it", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 6c153cd87268a..1bb74f42fdcab 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -510,7 +510,7 @@ export class BootService extends Context.Service< readonly restart: Effect.Effect<"restarted" | "queued" | "skipped", BootServiceError>; /** * Runs the operation with this home's service stopped; other homes are left alone. - * Restores a previously enabled or loaded service even if stopping failed. + * Restores a previously enabled or loaded service; launchd requires confirmed removal. * The operation's failure takes precedence over a restart failure. */ readonly withStopped: ( @@ -959,13 +959,21 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ).pipe(Effect.exit), (stopped): Effect.Effect => Exit.isSuccess(stopped) ? operation : Effect.failCause(stopped.cause), - (stopped, exit) => { - if (!restore || (manager.kind === "launchd" && Exit.isFailure(stopped))) return Effect.void; - // Start only: `activate` would also enable a unit the user disabled. - // When the operation itself failed, its error is the one worth reporting. - const start = runSteps(manager.restart); - return Exit.isFailure(exit) ? Effect.ignore(start) : start; - }, + (stopped, exit) => + Effect.gen(function* () { + if (!restore) return; + if (manager.kind === "launchd" && Exit.isFailure(stopped)) { + const result = yield* probe("launchctl", [ + "print", + `gui/${uid}/${BOOT_SERVICE_LAUNCHD_LABEL}`, + ]); + if (Option.isNone(result) || ![112, 113].includes(Number(result.value.code))) return; + } + // Start only: `activate` would also enable a unit the user disabled. + // When the operation itself failed, its error is the one worth reporting. + const start = runSteps(manager.restart); + return yield* Exit.isFailure(exit) ? Effect.ignore(start) : start; + }), ); });