diff --git a/src/cli/server.ts b/src/cli/server.ts index 03301b259d..78dda2f2c4 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -12,6 +12,8 @@ import { initializeXumHomeTransition } from "@/node/compat/xumTransition"; import { ServerLockfile } from "@/node/services/serverLockfile"; import { log } from "@/node/services/log"; import { shutdownStep } from "@/node/services/shutdownStep"; +import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; +import { SERVICE_TEARDOWN_BUDGET_MS } from "@/constants/terminationTimeouts"; import type { BrowserWindow } from "electron"; import { Command } from "commander"; import { validateProjectPath } from "@/node/utils/pathUtils"; @@ -49,6 +51,10 @@ process.on("beforeExit", (code) => { // Track the launch project path for initial navigation let launchProjectPath: string | null = null; +// Set as soon as the container exists so a startup that fails afterwards (main() rejecting) still +// runs the bounded teardown before the process exits. +let constructedServices: ServiceContainer | undefined; + // Minimal BrowserWindow stub for services that expect one // eslint-disable-next-line local/no-chained-type-assertions -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern const mockWindow: BrowserWindow = { @@ -131,6 +137,7 @@ async function main(): Promise { const stores = createConfigStores(); const config = stores.config; const serviceContainer = new ServiceContainer(stores); + constructedServices = serviceContainer; // Headless server has no interactive host-key dialog setOpenSSHHostKeyPolicyMode("headless-fallback"); // Core init (including agent-task recovery, which must finish before any client can act on @@ -247,11 +254,11 @@ async function main(): Promise { const forceExitTimer = setTimeout(() => { appendServerCrashLogSync({ event: "Server cleanup timed out", - context: { timeoutMs: 5000 }, + context: { timeoutMs: SERVICE_TEARDOWN_BUDGET_MS }, }); console.log("Cleanup timed out, forcing exit..."); process.exit(1); - }, 5000); + }, SERVICE_TEARDOWN_BUDGET_MS); try { // Close all PTY sessions first @@ -290,12 +297,28 @@ async function main(): Promise { process.on("SIGTERM", () => void cleanup()); } -void main().catch((error) => { +void main().catch(async (error: unknown) => { appendServerCrashLogSync({ event: "Failed to initialize server", detail: error, }); console.error("Failed to initialize server:", error); + if (constructedServices) { + // Parity with the desktop before-quit race and the ACP root: a startup step that failed — or + // timed out and is still running as a plain promise (StartupStepTimeoutError) — must not leave + // half-started services behind. Bounded like the SIGTERM cleanup; the process exits either way. + const teardown = await raceWithAbortAndTimeout( + constructedServices.dispose().catch((disposeError: unknown) => { + log.error("[shutdown] dispose after failed startup failed", { error: disposeError }); + }), + { timeoutMs: SERVICE_TEARDOWN_BUDGET_MS } + ); + if (teardown.kind === "timeout") { + log.warn("[shutdown] dispose after failed startup timed out; exiting", { + timeoutMs: SERVICE_TEARDOWN_BUDGET_MS, + }); + } + } process.exit(1); }); diff --git a/src/constants/terminationTimeouts.ts b/src/constants/terminationTimeouts.ts index fff1d5c100..78828c872f 100644 --- a/src/constants/terminationTimeouts.ts +++ b/src/constants/terminationTimeouts.ts @@ -30,3 +30,25 @@ export const APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS = 2 * 1000; * inside the same 5 s quit budgets. */ export const STARTUP_HOUSEKEEPING_JOIN_TIMEOUT_MS = 500; + +/** + * Outer budget the `xum server` and ACP roots give the whole + * `ServiceContainer.dispose()` — the SIGTERM cleanup and the dispose after a + * failed startup; `desktop/main.ts` races its before-quit dispose against the + * same 5 s. The bounded steps above are sized to fit inside it. + */ +export const SERVICE_TEARDOWN_BUDGET_MS = 5 * 1000; + +/** + * Bounds each hard startup step of `ServiceContainer.initializeCore()` on the app + * runtime's clock. A step that has not settled by then fails startup with a + * `StartupStepTimeoutError` through the same exit path as a throwing step + * (desktop "Startup Failed" dialog, `xum server`/ACP log-and-exit after the + * bounded `dispose()`), instead of pinning the splash screen or the listener + * bind forever. Deliberately generous — a false timeout turns a slow-but-fine + * start into a crash: sandbox cold starts measured ≤ 60 ms for the slowest core + * step (`taskService.recoverInterruptedTasks`, which scales with the number of + * active agent tasks, not with deployment size), so this is ≥ 1000× the observed + * maximum and still above the policy service's own 10 s fetch timeout. + */ +export const STARTUP_STEP_TIMEOUT_MS = 60 * 1000; diff --git a/src/node/acp/serverConnection.ts b/src/node/acp/serverConnection.ts index f4d270a00f..ee9540ce92 100644 --- a/src/node/acp/serverConnection.ts +++ b/src/node/acp/serverConnection.ts @@ -5,11 +5,13 @@ import { RPCLink as WebSocketRPCLink } from "@orpc/client/websocket"; import type { RouterClient } from "@orpc/server"; import WebSocket from "ws"; import { getXumHome } from "@/common/constants/paths"; +import { SERVICE_TEARDOWN_BUDGET_MS } from "@/constants/terminationTimeouts"; import { createConfigStores } from "@/node/config"; import type { AppRouter } from "@/node/orpc/router"; import { createOrpcServer } from "@/node/orpc/server"; import { ServiceContainer } from "@/node/services/serviceContainer"; import { ServerLockfile } from "@/node/services/serverLockfile"; +import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; interface ConnectViaWebSocketResult { client: ORPCClient; @@ -154,12 +156,10 @@ async function connectToInProcessServer(requestedAuthToken?: string): Promise undefined); } - if (initialized) { - await serviceContainer.dispose().catch(() => undefined); - } + // Also after a rejected initialize(): a startup step that failed — or timed out and is still + // running as a plain promise (StartupStepTimeoutError) — must not leave half-started services + // behind, and the stdio adapter must still exit if a teardown step hangs. dispose() is safe + // on a container that never finished initializing. + await raceWithAbortAndTimeout( + serviceContainer.dispose().catch(() => undefined), + { + timeoutMs: SERVICE_TEARDOWN_BUDGET_MS, + } + ); throw error; } diff --git a/src/node/services/di/appRuntime.ts b/src/node/services/di/appRuntime.ts index 2e8a1a4cd2..a51fc39741 100644 --- a/src/node/services/di/appRuntime.ts +++ b/src/node/services/di/appRuntime.ts @@ -96,6 +96,32 @@ * torn down before step 2 below, and never fork long-lived I/O work through * `EffectRunner` expecting shutdown to await it. * + * ## Startup (`ServiceContainer.initializeCore()`, Wave 4 PR 3) + * + * The hard startup steps (`startupCoreSteps`: the initializations request + * handling depends on, then agent-task recovery) run as one startup effect on + * the runtime — `runtime.managed.runPromise(startupCoreEffect())`, a + * root fiber on the built context, not a layer (I1 keeps layer bodies + * synchronous, so asynchronous acquisition lives here). Each step is a Promise + * thunk in `Effect.tryPromise` with an identity catch, bounded by + * `Effect.timeoutOrElse(STARTUP_STEP_TIMEOUT_MS)` on the runtime's `Clock` + * (tests inject a `TestClock` beneath the graph and drive the bound + * deterministically). Contract: step names/order are the `stepDurationsMs` + * keys of the completion log; the first failure or timeout rejects the facade + * with the step's own error (identity — v4 `runPromise` rejects with the raw + * failure) or a `StartupStepTimeoutError`, later steps do not run, and a + * timed-out step keeps running as a plain promise (the timeout interrupts only + * the wait; nothing observes the step's result afterwards). Every root + * therefore runs the bounded `dispose()` before exiting on a rejected startup + * (desktop before-quit race, `cli/server.ts` `main().catch`, ACP + * `connectToInProcessServer` catch) so an abandoned step's work is cut off by + * the same latches as a quit. Disposing the runtime does not interrupt an + * in-flight startup fiber (root fibers are not scope children), matching the + * promise chain it replaced. `runStartupHousekeeping()` stays a Promise + * pipeline on purpose: its steps are already cancellable through the dispose + * abort signal and non-fatal by policy, so a per-step timeout there would be a + * policy change, not a lifecycle fix. + * * ## Shutdown order (`ServiceContainer.dispose()`, one shared teardown behind * a latch so concurrent/repeated calls — the desktop's two `before-quit` * listeners, tests' dispose-then-shutdown — await the same sequence) @@ -142,12 +168,13 @@ * * ## Deliberately not done in Phase 11 * - * `initialize()` as a Layer/startup effect (would break I1's failure - * semantics), layer finalizers for the existing `dispose()` steps (I5), - * `streamBridge` on the runtime, per-service optional tags (optional - * cross-cutting services stay optional via `CoreOptionsTag`). The streamManager - * engine core became the `AppFiberScope` occupant in Wave 4 PR 1; the - * pre-registration stream-start window (`pendingStreamStarts`) stays + * Startup as a Layer (would break I1's failure semantics; it became a + * runtime-run effect instead, see "Startup"), layer finalizers for the existing + * `dispose()` steps (I5), `streamBridge` on the runtime, per-service optional + * tags (optional cross-cutting services stay optional via `CoreOptionsTag`), + * per-step timeouts for `runStartupHousekeeping()` (policy, see "Startup"). The + * streamManager engine core became the `AppFiberScope` occupant in Wave 4 PR 1; + * the pre-registration stream-start window (`pendingStreamStarts`) stays * unsupervised (nothing durable exists for it yet). */ import assert from "@/common/utils/assert"; diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 19d480f1f9..b58cbabc8d 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -13,6 +13,7 @@ import { AppFiberScopeTag } from "@/node/services/di/appFiberScope"; import { EffectRunnerTag } from "@/node/services/di/effectRunner"; import * as appLayers from "@/node/services/di/layers/app"; import { CoreOptionsTag } from "@/node/services/di/layers/core"; +import { STARTUP_STEP_TIMEOUT_MS } from "@/constants/terminationTimeouts"; import { AgentBrowserSessionDiscovery, AgentPluginInstall, @@ -78,7 +79,7 @@ import { WorktreeArchiveSnapshot, type AppTags, } from "@/node/services/di/tags"; -import { ServiceContainer } from "./serviceContainer"; +import { ServiceContainer, StartupStepTimeoutError } from "./serviceContainer"; /** * Independent field → tag listing for every ORPC context field (the production @@ -345,6 +346,160 @@ describe("ServiceContainer", () => { expect(agentStatusStart).toHaveBeenCalledTimes(1); }); + const CORE_STEP_NAMES = [ + "extensionMetadata.initialize", + "telemetryService.initialize", + "policyService.initialize", + "experimentsService.initialize", + "taskService.recoverInterruptedTasks", + ]; + + /** The container's private startup bookkeeping, read for assertions only. */ + function startupInternals(container: ServiceContainer) { + return container as unknown as { + extensionMetadata: { initialize: () => Promise }; + startupStepDurationsMs: Record; + }; + } + + /** The rejection reason of `promise` as-is (identity assertions), or a marker if it resolved. */ + function rejectionOf(promise: Promise): Promise { + return promise.then( + () => "", + (reason: unknown) => reason + ); + } + + it("initializeCore times out a hung step on the runtime clock and skips the later steps", async () => { + // TestClock beneath the real graph: the per-step bound must sleep on the runtime's clock + // (the effect runs through the ManagedRuntime, not a global Effect.runPromise). + const realAppLive = appLayers.AppLive; + const appLiveSpy = spyOn(appLayers, "AppLive").mockImplementation((appStores) => + realAppLive(appStores).pipe(Layer.provideMerge(TestClock.layer())) + ); + try { + services = new ServiceContainer(stores); + } finally { + appLiveSpy.mockRestore(); + } + const runtime = services.runtime.managed; + spyOn(startupInternals(services).extensionMetadata, "initialize").mockResolvedValue(undefined); + spyOn(services.telemetryService, "initialize").mockResolvedValue(undefined); + let policyCalled: (() => void) | undefined; + const policyCalledPromise = new Promise((resolve) => { + policyCalled = resolve; + }); + let rejectAbandonedStep: ((error: unknown) => void) | undefined; + spyOn(services.policyService, "initialize").mockImplementation(() => { + policyCalled?.(); + return new Promise((_resolve, reject) => { + rejectAbandonedStep = reject; + }); + }); + const experimentsInitialize = spyOn(services.experimentsService, "initialize"); + const recoverTasks = spyOn(services.taskService, "recoverInterruptedTasks"); + + let outcome: { settled: boolean; error?: unknown } = { settled: false }; + const core = services.initializeCore().then( + () => { + outcome = { settled: true }; + }, + (error: unknown) => { + outcome = { settled: true, error }; + } + ); + await policyCalledPromise; + + // One millisecond short of the budget the wait is still pending... + await runtime.runPromise(TestClock.adjust(Duration.millis(STARTUP_STEP_TIMEOUT_MS - 1))); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(outcome.settled).toBe(false); + // ...and exactly at the budget the step is abandoned. + await runtime.runPromise(TestClock.adjust(Duration.millis(1))); + await core; + expect(outcome.error).toBeInstanceOf(StartupStepTimeoutError); + const timeoutError = outcome.error as StartupStepTimeoutError; + expect(timeoutError.step).toBe("policyService.initialize"); + expect(timeoutError.timeoutMs).toBe(STARTUP_STEP_TIMEOUT_MS); + // The roots' default Error formatting (dialog / log line) names the class and the step. + expect(String(timeoutError)).toMatch(/^StartupStepTimeoutError: policyService\.initialize /); + expect(experimentsInitialize).not.toHaveBeenCalled(); + expect(recoverTasks).not.toHaveBeenCalled(); + const durations = startupInternals(services).startupStepDurationsMs; + expect(Object.keys(durations)).toEqual(CORE_STEP_NAMES.slice(0, 3)); + const durationsAtTimeout = { ...durations }; + + // The abandoned step keeps running as a plain promise: its late rejection is neither + // unhandled nor a late side effect on the container. + const unhandled: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + try { + rejectAbandonedStep?.(new Error("late policy failure")); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + expect(unhandled).toEqual([]); + expect(durations).toEqual(durationsAtTimeout); + expect(experimentsInitialize).not.toHaveBeenCalled(); + expect(recoverTasks).not.toHaveBeenCalled(); + }); + + it("initializeCore rejects with the failing step's own error and skips the later steps", async () => { + services = new ServiceContainer(stores); + const boom = new Error("policy endpoint unreachable"); + spyOn(services.policyService, "initialize").mockImplementation(() => Promise.reject(boom)); + const experimentsInitialize = spyOn(services.experimentsService, "initialize"); + const recoverTasks = spyOn(services.taskService, "recoverInterruptedTasks"); + + // Identity, not a wrapped copy: roots log/print the object they receive. + expect(await rejectionOf(services.initializeCore())).toBe(boom); + expect(experimentsInitialize).not.toHaveBeenCalled(); + expect(recoverTasks).not.toHaveBeenCalled(); + }); + + it("initializeCore rejects with a synchronously thrown step error", async () => { + services = new ServiceContainer(stores); + const boom = new Error("policy store corrupt"); + spyOn(services.policyService, "initialize").mockImplementation(() => { + throw boom; + }); + const recoverTasks = spyOn(services.taskService, "recoverInterruptedTasks"); + + expect(await rejectionOf(services.initializeCore())).toBe(boom); + expect(recoverTasks).not.toHaveBeenCalled(); + }); + + it("initializeCore records the five core steps and re-runs them when called again", async () => { + services = new ServiceContainer(stores); + const recoverTasks = spyOn(services.taskService, "recoverInterruptedTasks").mockResolvedValue( + undefined + ); + + await services.initializeCore(); + expect(Object.keys(startupInternals(services).startupStepDurationsMs)).toEqual(CORE_STEP_NAMES); + // Not re-entrancy guarded (parity with the plain promise chain it replaced). + await services.initializeCore(); + expect(recoverTasks).toHaveBeenCalledTimes(2); + }); + + it("initializeCore after dispose() fails fast without running a step", async () => { + services = new ServiceContainer(stores); + const recoverTasks = spyOn(services.taskService, "recoverInterruptedTasks"); + await services.dispose(); + + // A disposed ManagedRuntime would otherwise reject with a bare "ManagedRuntime disposed" + // defect string from inside the first step. + const rejection = await rejectionOf(services.initializeCore()); + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toContain("after dispose()"); + expect(recoverTasks).not.toHaveBeenCalled(); + }); + it("exposes desktopSessionManager in the ORPC context", () => { services = new ServiceContainer(stores); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 1234247a58..8e9a297c4e 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -1,8 +1,12 @@ +import assert from "@/common/utils/assert"; import { log } from "@/node/services/log"; import type { Config, ConfigStores, WorkspaceSessionLocator } from "@/node/config"; import type { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; import { SLOW_STARTUP_WARN_THRESHOLD_MS } from "@/constants/startup"; -import { STARTUP_HOUSEKEEPING_JOIN_TIMEOUT_MS } from "@/constants/terminationTimeouts"; +import { + STARTUP_HOUSEKEEPING_JOIN_TIMEOUT_MS, + STARTUP_STEP_TIMEOUT_MS, +} from "@/constants/terminationTimeouts"; import type { CoreServices } from "@/node/services/coreServices"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; import type { ProjectService } from "@/node/services/projectService"; @@ -49,6 +53,7 @@ import type { DesktopBridgeServer } from "@/node/services/desktop/DesktopBridgeS import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; import type { DesktopTokenManager } from "@/node/services/desktop/DesktopTokenManager"; import type { ORPCContext } from "@/node/orpc/context"; +import { Duration, Effect } from "effect"; import type { Scope } from "effect"; import { AppFiberScopeTag } from "@/node/services/di/appFiberScope"; import { @@ -128,6 +133,31 @@ import { WorkspaceTurnManagerTag, type AppTags, } from "@/node/services/di/tags"; + +/** + * A hard startup step of `ServiceContainer.initializeCore()` did not settle + * within `STARTUP_STEP_TIMEOUT_MS`. Rejects `initializeCore()` like any other + * step failure, so the roots' existing startup-failure paths apply unchanged; + * `name` is set explicitly so their default `Error` formatting (desktop + * "Startup Failed" dialog, `Failed to initialize server:` line) shows the + * class together with the step. + */ +export class StartupStepTimeoutError extends Error { + constructor( + readonly step: string, + readonly timeoutMs: number + ) { + super(`${step} exceeded ${timeoutMs} ms`); + this.name = "StartupStepTimeoutError"; + } +} + +interface StartupStep { + /** `stepDurationsMs` key of the startup completion log and `StartupStepTimeoutError.step`. */ + readonly name: string; + readonly run: () => Promise; +} + /** * ServiceContainer - Central dependency container for all backend services. * @@ -306,6 +336,10 @@ export class ServiceContainer { this.idleDispatcher = get(IdleDispatcherTag); this.heartbeatService = get(Heartbeat); this.agentStatusService = get(AgentStatus); + assert( + new Set(this.startupCoreSteps.map((step) => step.name)).size === this.startupCoreSteps.length, + "startupCoreSteps names must be unique (they key stepDurationsMs)" + ); } async initialize(): Promise { @@ -323,35 +357,91 @@ export class ServiceContainer { } /** - * Everything request handling depends on, plus agent-task restart recovery. The server entry - * point awaits this before binding its listener: task recovery must finish before any client - * can stop, resume, or send to a task (see TaskService.recoverInterruptedTasks), and it is - * bounded by the number of active tasks rather than by deployment size. The per-workspace + * The hard startup steps, in order: everything request handling depends on, plus agent-task + * restart recovery. All five are mandatory — a failure stops startup — and every name is a + * `stepDurationsMs` key of the `[startup] ServiceContainer.initialize completed` line (and the + * `step` of a `StartupStepTimeoutError`), so names and order are an observability contract. + * Downgrading a step to best-effort is runStartupHousekeeping()'s policy, not a change here. + */ + private readonly startupCoreSteps: readonly StartupStep[] = [ + { name: "extensionMetadata.initialize", run: () => this.extensionMetadata.initialize() }, + { name: "telemetryService.initialize", run: () => this.telemetryService.initialize() }, + // Startup gating + { name: "policyService.initialize", run: () => this.policyService.initialize() }, + { name: "experimentsService.initialize", run: () => this.experimentsService.initialize() }, + { + name: "taskService.recoverInterruptedTasks", + run: () => this.taskService.recoverInterruptedTasks(), + }, + ]; + + /** + * Runs `startupCoreSteps` on the app runtime (startup contract in di/appRuntime.ts). The + * server entry point awaits this before binding its listener: task recovery must finish before + * any client can stop, resume, or send to a task (see TaskService.recoverInterruptedTasks), and + * it is bounded by the number of active tasks rather than by deployment size. The per-workspace * housekeeping lives in runStartupHousekeeping(). + * + * Rejects with the failing step's own error (identity preserved — a synchronous throw included) + * or with a `StartupStepTimeoutError` once a step exceeds `STARTUP_STEP_TIMEOUT_MS` on the + * runtime clock; later steps do not run. A timed-out step keeps running as a plain promise + * (nothing here observes its result afterwards), which is why every root runs the bounded + * `dispose()` before exiting on a rejected startup. Not re-entrancy guarded: a second call + * re-runs the steps, as the plain promise chain did. */ async initializeCore(): Promise { - this.startupStartedAt = Date.now(); - - log.info("[startup] ServiceContainer.initialize starting"); - - await this.recordStartupStep("extensionMetadata.initialize", () => - this.extensionMetadata.initialize() - ); - // Initialize telemetry service - await this.recordStartupStep("telemetryService.initialize", () => - this.telemetryService.initialize() - ); - - // Initialize policy service (startup gating) - await this.recordStartupStep("policyService.initialize", () => this.policyService.initialize()); + assert(this.disposePromise === null, "ServiceContainer.initializeCore() after dispose()"); + await this.runtime.managed.runPromise(this.startupCoreEffect()); + } - await this.recordStartupStep("experimentsService.initialize", () => - this.experimentsService.initialize() - ); + private startupCoreEffect(): Effect.Effect { + // eslint-disable-next-line @typescript-eslint/no-this-alias -- Effect.gen generator bodies do not inherit `this` + const self = this; + return Effect.gen(function* () { + self.startupStartedAt = Date.now(); + log.info("[startup] ServiceContainer.initialize starting"); + for (const step of self.startupCoreSteps) { + yield* self.timedStartupStep(step); + } + }); + } - await this.recordStartupStep("taskService.recoverInterruptedTasks", () => - this.taskService.recoverInterruptedTasks() - ); + /** + * One startup step as an effect. `tryPromise` with an identity catch keeps the rejection + * reason as the failure; the `async` thunk turns a synchronous throw into the same path. + * `timeoutOrElse` (not `timeout` + `catchTag`: the error channel is `unknown`, which + * `catchTag` cannot narrow) races the wait against the runtime clock and interrupts only the + * wait — the zero-arity thunk gets no AbortSignal, so the promise keeps running and its + * eventual settlement is a no-op on the exited fiber (`tryPromise` keeps a rejection handler + * attached, so a late rejection is never unhandled). The duration is recorded when the wait + * ends — settled, failed, or abandoned at the timeout — never by the abandoned step later. + */ + private timedStartupStep(step: StartupStep): Effect.Effect { + return Effect.suspend(() => { + const stepStartedAt = Date.now(); + return Effect.tryPromise({ + try: async () => step.run(), + catch: (error: unknown) => error, + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(STARTUP_STEP_TIMEOUT_MS), + // Fatal on purpose — the same exit path a throwing step already takes on every root + // (desktop "Startup Failed" dialog, server/ACP log-and-exit). These are the hard steps + // request handling depends on (#4058): continuing past a timed-out step would, e.g., let + // the server accept task operations while task recovery is still running. The + // "startup must never crash the app" rule governs the best-effort work in + // runStartupHousekeeping(), which stays non-fatal; this bound only turns an indefinite + // hang (splash pinned, listener never bound, no dispose) into the existing failure path. + orElse: () => + Effect.fail(new StartupStepTimeoutError(step.name, STARTUP_STEP_TIMEOUT_MS)), + }), + Effect.ensuring( + Effect.sync(() => { + this.startupStepDurationsMs[step.name] = Date.now() - stepStartedAt; + }) + ) + ); + }); } /**