From 6bad4c9d140046a373d2b5d81aee23698c998d28 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 16:32:38 -0400 Subject: [PATCH 1/2] fix(native): bound adoption and authored product clocks by the caller deadline (HACK-1458) Public adoption dispatch now starts one command deadline before it opens the store. Each store inspection phase takes the time left on that deadline, capped at 60 s, instead of a fixed 15 s clock, and an expired deadline returns E_STARTUP_TIMEOUT with the store's fixed deadline message instead of E_CONFIG_INVALID. Authored startup compiler calls are bounded by the remaining startup deadline (floor 1 ms, ceiling 60 s) instead of a fixed 10 s each. Compiler calls outside startup, including the down-hook acquisition after a cancel, take the 60 s ceiling: the fixed 10 s clock failed the graceful stop under load, which then retained the live owner and host processes until a hard cancel (the "Source5 cancellation after readiness" hang). A start that fails or is cancelled after host processes began and before reservation now stops them: their teardown re-acquires inputs under the hard signal, because the startup inputs refuse once the startup signal aborts. A host process launch that fails part-way ends its owned session through the token it created. Co-Authored-By: Claude Opus 5.5 --- docs/reference/native-authored-commands.md | 8 + docs/reference/native-compose-adoption.md | 7 +- .../native-authored-host-processes.ts | 184 +++++++----- src/backends/native-authored-project-start.ts | 44 ++- src/lib/native-compose-adoption-command.ts | 63 ++-- src/lib/native-compose-adoption-generation.ts | 130 +++++++-- src/lib/native-config-compiler.ts | 24 +- src/lib/native-execution-inputs.ts | 7 + src/lib/native-project-validation.ts | 10 + tests/native-authored-project-start.test.ts | 269 ++++++++++++++++++ ...native-compose-adoption-generation.test.ts | 88 ++++++ 11 files changed, 730 insertions(+), 104 deletions(-) diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 2f95844bc..4e87e526e 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -87,6 +87,14 @@ report installed live acceptance. Startup shares the configured `HACK_COMPOSE_STARTUP_TIMEOUT_MS` budget across input preparation and native review, with a native maximum of 300000 milliseconds. +Each startup compiler call is bounded by the time the startup budget has left, +capped at 60 seconds, rather than by a fixed 10-second clock. Compiler calls outside +startup (the down hooks' input check after Ctrl-C or a stop request, and checks after +ready) have no startup budget and take the 60-second cap, so a slow host does not +fail a graceful stop (HACK-1458). A start that fails or is cancelled after host +processes began and before its startup intent exists stops those processes, or ends +the launch it had begun through its own session token, before it returns. A +teardown it cannot prove keeps the host process records retained. Startup refusals identify a fixed owning stage and, when available, a closed compiler or native error code. They omit arbitrary errors, child output, paths and values. If startup fails before publishing a receipt and the later retirement diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index cb3e77d9c..3c1bd24b1 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -136,7 +136,12 @@ stop recovery can clear it. A retained operation that reaches its caller deadlin refuses with its own fixed message ("reached its caller deadline"), not the source-or-binding-changed message, which only an observed drift issues (HACK-1340). `hack up`, `hack down` and `config adopt --stop` pass the Compose -startup budget as that deadline. These rechecks do not freeze external editors or +startup budget as that deadline. Every adopted project command (`up --detach`, +`restart`, `down`, `ps`, `logs`, `exec`) starts one such deadline before it opens the +store, and each store inspection phase takes the time it has left, capped at 60 +seconds, instead of a fixed 15-second clock. When that deadline expires the command +returns `E_STARTUP_TIMEOUT` with the "reached its caller deadline" message, not +`E_CONFIG_INVALID` (HACK-1458). These rechecks do not freeze external editors or the engine atomically. Public output remains field/count/status metadata; capabilities and authored probe arguments are private. Prior versions 1–4 keep their existing execution behavior, and older upgraded owners refuse version 5 diff --git a/src/backends/native-authored-host-processes.ts b/src/backends/native-authored-host-processes.ts index f71e92f50..f67a5ae30 100644 --- a/src/backends/native-authored-host-processes.ts +++ b/src/backends/native-authored-host-processes.ts @@ -98,6 +98,9 @@ async function unchanged(saved: Awaited>) { return refuse(); } } +/** An abandoned launch's groups get 5 s to exit after their session ends. */ +const GROUP_SETTLEMENT_INTERVAL_MS = 50; +const GROUP_SETTLEMENT_ATTEMPTS = 100; function absentGroup(group: number): void { try { process.kill(-group, 0); @@ -109,6 +112,20 @@ function absentGroup(group: number): void { } refuse(); } +/** Bounded wait for an owned group to exit after its session was ended. */ +async function settledGroup(group: number): Promise { + for (let attempt = 1; ; attempt++) { + try { + absentGroup(group); + return; + } catch (error) { + if (attempt >= GROUP_SETTLEMENT_ATTEMPTS) { + throw error; + } + } + await Bun.sleep(GROUP_SETTLEMENT_INTERVAL_MS); + } +} function entry(value: unknown): LifecycleStateEntry | null { if (value === null) { return null; @@ -539,6 +556,7 @@ export async function createNativeAuthoredHostProcesses(input: { let active = true; let began = false; let launchIntent = false; + let abandoned = false; let stopped = false; let ready: Awaited> | undefined; let stateFile: Awaited> | undefined; @@ -560,6 +578,8 @@ export async function createNativeAuthoredHostProcesses(input: { await recheckDirectories(directories); await unchanged(saved); }; + /** Groups this owner launched, recorded before their launch completes. */ + const launchedGroups = new Set(); const deliveries = new Set< Awaited> >(); @@ -630,6 +650,7 @@ export async function createNativeAuthoredHostProcesses(input: { if (!(value.processGroupId && value.panePid)) { return refuse(); } + launchedGroups.add(value.processGroupId); await delivery.started(); await check(); opts.remaining(); @@ -640,6 +661,36 @@ export async function createNativeAuthoredHostProcesses(input: { }; }, }); + /** + * HACK-1458: a start that fails after its launch intent (a cancel, the + * startup deadline or a refused check) ends what it launched through the + * session ownership token it created, rather than leaving the groups + * running behind an intent that recovery never converts into authority. + * Only a recorded, verified-absent launch clears the owner bookkeeping; + * anything unproven stays retained. + */ + const abandonLaunch = async () => { + try { + const launched = await currentEntry(opts.scope, composeProject); + await controller.abort(); + if ((await currentEntry(opts.scope, composeProject)) !== null) { + return; + } + const groups = new Set(launchedGroups); + for (const process of launched?.processes ?? []) { + groups.add(process.processGroupId ?? refuse()); + } + if (controller.hasStarted() && groups.size === 0) { + return; + } + for (const group of groups) { + await settledGroup(group); + } + abandoned = true; + } catch { + // Unproven teardown keeps the launch intent and owner retained. + } + }; const assertReady = async () => { await check(); if (!ready || stopped) { @@ -704,74 +755,79 @@ export async function createNativeAuthoredHostProcesses(input: { opts.remaining(); } launchIntent = true; - await writeExclusive( - join(root, `${opts.run}.host-process-intent.json`), - JSON.stringify({ - version: 1, - kind: "native-authored-host-process-intent", - run: opts.run, - owner: saved.pin, - }) - ); - await synchronizeDirectories(directories); - await check(); - opts.remaining(); - if (processes.length !== 0) { - await controller.startMany({ processes }); - } - await controller.finalize(); - savedEntry = await currentEntry(opts.scope, composeProject); - if ((savedEntry !== null) !== controller.hasStarted()) { - return refuse(); - } - if ( - savedEntry && - (savedEntry.definitionHash !== opts.semanticHash || - savedEntry.projectName !== projectName || - savedEntry.processes.some( - (process) => !owner.names.includes(process.name) - )) - ) { - return refuse(); + try { + await writeExclusive( + join(root, `${opts.run}.host-process-intent.json`), + JSON.stringify({ + version: 1, + kind: "native-authored-host-process-intent", + run: opts.run, + owner: saved.pin, + }) + ); + await synchronizeDirectories(directories); + await check(); + opts.remaining(); + if (processes.length !== 0) { + await controller.startMany({ processes }); + } + await controller.finalize(); + savedEntry = await currentEntry(opts.scope, composeProject); + if ((savedEntry !== null) !== controller.hasStarted()) { + return refuse(); + } + if ( + savedEntry && + (savedEntry.definitionHash !== opts.semanticHash || + savedEntry.projectName !== projectName || + savedEntry.processes.some( + (process) => !owner.names.includes(process.name) + )) + ) { + return refuse(); + } + await check(); + opts.remaining(); + await writeExclusive( + join(root, `${opts.run}.host-process-state.json`), + JSON.stringify({ + version: 1, + kind: "native-authored-host-process-state", + run: opts.run, + owner: saved.pin, + entry: savedEntry, + }) + ); + await writeExclusive( + join(root, `${opts.run}.host-process-ready.json`), + JSON.stringify({ + version: 1, + kind: "native-authored-host-process-ready", + run: opts.run, + project: owner.project, + branch: owner.branch, + semantic_hash: owner.semantic_hash, + owner: saved.pin, + pid: owner.pid, + uid: owner.uid, + }) + ); + await synchronizeDirectories(directories); + ready = await capture( + join(root, `${opts.run}.host-process-ready.json`) + ); + stateFile = await capture( + join(root, `${opts.run}.host-process-state.json`) + ); + await assertReady(); + } catch (error) { + await abandonLaunch(); + throw error; } - await check(); - opts.remaining(); - await writeExclusive( - join(root, `${opts.run}.host-process-state.json`), - JSON.stringify({ - version: 1, - kind: "native-authored-host-process-state", - run: opts.run, - owner: saved.pin, - entry: savedEntry, - }) - ); - await writeExclusive( - join(root, `${opts.run}.host-process-ready.json`), - JSON.stringify({ - version: 1, - kind: "native-authored-host-process-ready", - run: opts.run, - project: owner.project, - branch: owner.branch, - semantic_hash: owner.semantic_hash, - owner: saved.pin, - pid: owner.pid, - uid: owner.uid, - }) - ); - await synchronizeDirectories(directories); - ready = await capture( - join(root, `${opts.run}.host-process-ready.json`) - ); - stateFile = await capture( - join(root, `${opts.run}.host-process-state.json`) - ); - await assertReady(); }, assertReady, async stop() { - if (!launchIntent) { + if (!launchIntent || abandoned) { stopped = true; return; } diff --git a/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index a0fc2e8c2..bbf20ee9f 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -6,7 +6,11 @@ import { } from "../lib/compose-env-fingerprint.ts"; import { deepFreeze } from "../lib/guards.ts"; import { NativeComposeHostHookError } from "../lib/native-compose-host-contract.ts"; -import { NativeConfigCompilerError } from "../lib/native-config-compiler.ts"; +import { + NATIVE_CONFIG_COMPILER_MAX_TIMEOUT_MS, + NativeConfigCompilerError, + nativeConfigCompilerTimeoutWithin, +} from "../lib/native-config-compiler.ts"; import { acquireNativeExecutionInputs } from "../lib/native-execution-inputs.ts"; import { assertNativeFiniteHookBindings, @@ -823,14 +827,17 @@ async function stopUnstartedHostProcesses(opts: { readonly processes: NativeAuthoredHostProcesses | undefined; readonly attempt: Attempt | undefined; readonly outcome: Outcome; + /** Re-acquires teardown inputs that do not depend on the startup signal. */ + readonly refresh?: () => Promise; }): Promise<{ readonly processes: NativeAuthoredHostProcesses | undefined; readonly outcome: Outcome; }> { if (!opts.processes || opts.attempt) { - return opts; + return { processes: opts.processes, outcome: opts.outcome }; } try { + await opts.refresh?.(); await opts.processes.stop(); await opts.processes.close(); return { processes: undefined, outcome: opts.outcome }; @@ -914,6 +921,18 @@ export async function serveNativeAuthoredProject( } return time; }; + // HACK-1458: compiler calls never keep the compiler's fixed 10 s default. + // Startup calls share the startup deadline. Calls that are not part of + // startup (down-hook acquisition, and checks after publication) have no + // startup deadline, so each takes the transport's largest budget: a slow + // host must not fail a graceful stop, which would retain the live owner + // until a hard cancellation. + const startupCompilerTimeoutMs = () => + published + ? NATIVE_CONFIG_COMPILER_MAX_TIMEOUT_MS + : nativeConfigCompilerTimeoutWithin(deadline - performance.now()); + const settlementCompilerTimeoutMs = () => + NATIVE_CONFIG_COMPILER_MAX_TIMEOUT_MS; const abort = () => { if (!published) { controller.abort(); @@ -947,6 +966,7 @@ export async function serveNativeAuthoredProject( let nativeCauseCode: string | undefined; let hookOwner: NativeAuthoredHookOwner | undefined; let hostProcesses: NativeAuthoredHostProcesses | undefined; + let teardownInputs: (() => Promise) | undefined; let storageTool: NativeAuthoredStorageTool | undefined; let hookStop: | { readonly close: (force?: boolean) => Promise } @@ -981,6 +1001,10 @@ export async function serveNativeAuthoredProject( httpsPort: opts.httpsPort ?? null, }, signal, + compilerTimeoutMs: + signal === controller.signal + ? startupCompilerTimeoutMs + : settlementCompilerTimeoutMs, }); let inputs = await acquire(controller.signal); if (nativeAuthoredStorageToolRequired(inputs.result.plan)) { @@ -1002,6 +1026,21 @@ export async function serveNativeAuthoredProject( const { hooks, persistent } = selectedHost; hookOwner = selectedHost.owner; hostProcesses = selectedHost.processes; + // HACK-1458: inputs acquired for startup check freshness under the + // startup signal and deadline, so after a cancel or an expired + // deadline host process teardown could never pass its own freshness + // check. Teardown re-acquires them under the hard signal, like the + // down hooks, and still refuses a changed selection. + teardownInputs = async () => { + const current = await acquire(hardSignal.signal); + if ( + hookSelection(current) !== identity || + current.result.semantic_hash !== inputs.result.semantic_hash + ) { + throw new Error("Native host source changed; values omitted."); + } + inputs = current; + }; const phase = hookPhaseRunner({ owner: hookOwner, hooks, @@ -1183,6 +1222,7 @@ export async function serveNativeAuthoredProject( processes: hostProcesses, attempt, outcome, + refresh: teardownInputs, }); hostProcesses = host.processes; outcome = host.outcome; diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index b8065bc84..11083c537 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -8,6 +8,7 @@ import { legacyComposeOrderedRefusal } from "./native-compose-adoption-diagnosti import { runLegacyComposeRetainedOperation } from "./native-compose-adoption-execution.ts"; import { LegacyComposeAdoptedGenerationError, + legacyAdoptionProbePhaseMs, openLegacyComposeAdoptedGenerationStore, } from "./native-compose-adoption-generation.ts"; import { inspectLegacyComposeAdoptionSelection } from "./native-compose-adoption-marker.ts"; @@ -43,6 +44,37 @@ function unsupported(): never { "Adopted Compose supports retained-container start, restart, stop, ps, logs and exec only. Recreation, run and changed selections are unsupported; original data is retained. Values omitted.", }); } +/** + * Closed public refusal of an adopted command. A caller-deadline expiry is a + * timeout, not a config fault (HACK-1458); the fixed store message is kept + * for closed diagnostics. + */ +function throwAdoptedCommandRefusal(error: unknown): never { + if (error instanceof HackCliError || error instanceof CliUsageError) { + throw error; + } + const ordered = legacyComposeOrderedRefusal(error); + throw new HackCliError({ + code: + error instanceof LegacyComposeAdoptedGenerationError && + error.code === "E_LEGACY_ADOPTION_DEADLINE" + ? "E_STARTUP_TIMEOUT" + : "E_CONFIG_INVALID", + message: + error instanceof LegacyComposeAdoptedGenerationError + ? error.message + : LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.refused, + ...(ordered ? { detail: { legacy_adoption_refusal: ordered } } : {}), + }); +} +/** Time left before the command deadline; an expired deadline refuses as one. */ +function remainingMs(deadline: number): number { + const remaining = Math.floor(deadline - Date.now()); + if (remaining <= 0) { + throw new LegacyComposeAdoptedGenerationError("E_LEGACY_ADOPTION_DEADLINE"); + } + return remaining; +} function cancelled(signal: AbortSignal) { if (signal.aborted) { throw new HackCliError({ @@ -215,6 +247,7 @@ async function observe(opts: { readonly binding: LegacyComposeVerifiedBinding; readonly signal: AbortSignal; readonly timeoutMs: number; + readonly deadline: number; }) { const { options, binding, signal, timeoutMs } = opts; cancelled(signal); @@ -223,6 +256,10 @@ async function observe(opts: { const states = await inspectLegacyComposeContainerStates({ binding, signal, + timeoutMs: legacyAdoptionProbePhaseMs({ + deadline: opts.deadline, + now: Date.now(), + }), }); const report = containers.map((container) => ({ service: container.service, @@ -290,13 +327,17 @@ export async function tryLegacyComposeAdoptedCommand( process.on("SIGINT", cancel); process.on("SIGTERM", cancel); try { + // One caller deadline spans the whole command: store inspection, the + // retained operation and its final proof (HACK-1458). const signal = controller.signal, timeoutMs = resolveComposeStartupTimeoutMs(), + deadline = Date.now() + timeoutMs, store = await openLegacyComposeAdoptedGenerationStore({ projectRoot, requestedBranch: options.instance, mode: "saved", signal, + deadline, }); try { if (options.recover) { @@ -311,7 +352,6 @@ export async function tryLegacyComposeAdoptedCommand( const requested = requestedServices(options); const operation = mutationOperation(options.operation); if (operation) { - const deadline = Date.now() + timeoutMs; return await store.withMutation({ generation, operation, @@ -354,7 +394,7 @@ export async function tryLegacyComposeAdoptedCommand( stdout: "ignore", stderr: "ignore", forwardSignals: true, - timeoutMs, + timeoutMs: remainingMs(deadline), } ); }, @@ -369,29 +409,14 @@ export async function tryLegacyComposeAdoptedCommand( binding: privateInput.binding, signal, timeoutMs, + deadline, }), }); } finally { await store.close(); } } catch (error: unknown) { - if (error instanceof HackCliError || error instanceof CliUsageError) { - throw error; - } - throw new HackCliError({ - code: "E_CONFIG_INVALID", - message: - error instanceof LegacyComposeAdoptedGenerationError - ? error.message - : LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES.refused, - ...(legacyComposeOrderedRefusal(error) - ? { - detail: { - legacy_adoption_refusal: legacyComposeOrderedRefusal(error), - }, - } - : {}), - }); + throwAdoptedCommandRefusal(error); } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 8cb9eda4d..914c47da3 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -82,7 +82,10 @@ import { type LegacyComposeContainerState, } from "./native-compose-adoption-runtime.ts"; import { holdSavedLegacyComposeSourceBind } from "./native-compose-adoption-source-bind.ts"; -import { createNativeComposeProbe } from "./native-compose-ownership.ts"; +import { + createNativeComposeProbe, + nativeComposeProbeFailure, +} from "./native-compose-ownership.ts"; import { createNativeComposePrivateMutationLock, type HeldDirectory, @@ -271,12 +274,77 @@ function cancelled(signal?: AbortSignal) { refuse("E_LEGACY_ADOPTION_CANCELLED"); } } -function translate(error: unknown, signal?: AbortSignal): never { +/** + * Default aggregate clock of one store inspection phase (one probe owner) when + * the caller names neither a phase bound nor a deadline. + */ +export const LEGACY_ADOPTION_PROBE_PHASE_DEFAULT_MS = 15_000; +/** Largest aggregate clock one probe owner accepts. */ +export const LEGACY_ADOPTION_PROBE_PHASE_MAX_MS = 60_000; + +/** + * Phase budget of a store opened with a caller deadline (HACK-1458): each new + * inspection phase gets the caller's remaining time, capped by the explicit + * phase bound (if any) and {@link LEGACY_ADOPTION_PROBE_PHASE_MAX_MS}. Without a + * deadline the explicit bound passes through unchanged (undefined keeps each + * owner's own default). An expired deadline refuses as + * `E_LEGACY_ADOPTION_DEADLINE` before another probe starts. + */ +export function legacyAdoptionProbePhaseMs(opts: { + readonly timeoutMs?: number; + readonly deadline?: number; + readonly now: number; +}): number | undefined { + if (opts.deadline === undefined) { + return opts.timeoutMs; + } + const remaining = Math.floor(opts.deadline - opts.now); + if (remaining <= 0) { + refuse("E_LEGACY_ADOPTION_DEADLINE"); + } + return Math.min( + opts.timeoutMs ?? LEGACY_ADOPTION_PROBE_PHASE_MAX_MS, + LEGACY_ADOPTION_PROBE_PHASE_MAX_MS, + remaining + ); +} + +/** + * A store with a caller deadline reports an unclassified failure as a + * deadline, not as invalid state, when a probe phase timed out or the deadline + * has passed: phase clocks are derived from that deadline (HACK-1458). + */ +function deadlineExpiry(error: unknown, deadline?: number): boolean { + return ( + deadline !== undefined && + (nativeComposeProbeFailure(error) === "timeout" || Date.now() >= deadline) + ); +} + +function acceptedDeadline(value: unknown): boolean { + return value === undefined || Number.isFinite(value); +} + +function translate( + error: unknown, + signal?: AbortSignal, + deadline?: number +): never { try { cancelled(signal); if (error instanceof LegacyComposeAdoptedGenerationError) { throw error; } + if (deadlineExpiry(error, deadline)) { + const expired = new LegacyComposeAdoptedGenerationError( + "E_LEGACY_ADOPTION_DEADLINE" + ); + const diagnostic = legacyComposeOrderedRefusal(error); + if (diagnostic) { + attachLegacyComposeOrderedRefusal(expired, diagnostic); + } + throw expired; + } if ( error instanceof NativeComposeGenerationError && error.code === "E_NATIVE_COMPOSE_BUSY" @@ -2212,7 +2280,11 @@ async function completePublication( refuse(); } const routingDeadline = - Date.now() + Math.min(ctx.timeoutMs ?? 15_000, 60_000); + Date.now() + + Math.min( + ctx.timeoutMs ?? LEGACY_ADOPTION_PROBE_PHASE_DEFAULT_MS, + LEGACY_ADOPTION_PROBE_PHASE_MAX_MS + ); stage = "publication-inputs"; const loaded = await readInputs(ctx, publication.generation); // Publishing pins the bound override for the adoption's life. An earlier @@ -2350,7 +2422,11 @@ async function completeRollback(ctx: Context, state: Receipt) { refuse(); } const routingDeadline = - Date.now() + Math.min(ctx.timeoutMs ?? 15_000, 60_000); + Date.now() + + Math.min( + ctx.timeoutMs ?? LEGACY_ADOPTION_PROBE_PHASE_DEFAULT_MS, + LEGACY_ADOPTION_PROBE_PHASE_MAX_MS + ); const loaded = await readInputs(ctx, publication.generation, false, "saved"); await requireStopped(ctx, loaded.inputs.binding); const held = await holdDirectory( @@ -2572,7 +2648,11 @@ function boundedMutationContext(ctx: Context, deadline: number) { ...ctx, signal: controller.signal, get timeoutMs() { - return Math.min(ctx.timeoutMs ?? 15_000, 60_000, remaining()); + return Math.min( + ctx.timeoutMs ?? LEGACY_ADOPTION_PROBE_PHASE_DEFAULT_MS, + LEGACY_ADOPTION_PROBE_PHASE_MAX_MS, + remaining() + ); }, check: async () => { remaining(); @@ -3241,10 +3321,18 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { readonly requestedBranch?: string; readonly mode?: "prepare" | "saved"; readonly signal?: AbortSignal; + /** Explicit bound of each inspection phase; see {@link legacyAdoptionProbePhaseMs}. */ readonly timeoutMs?: number; + /** + * Caller deadline (epoch ms, `Date.now()` clock). Every inspection phase is + * bounded by its remaining time and its expiry refuses as + * `E_LEGACY_ADOPTION_DEADLINE`, never as invalid state. + */ + readonly deadline?: number; }): Promise { const directories: HeldDirectory[] = []; let signal: AbortSignal | undefined; + let deadline: number | undefined; try { if ( !isRecord(input) || @@ -3254,6 +3342,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { (input.signal !== undefined && !(input.signal instanceof AbortSignal)) || (input.mode !== undefined && !["prepare", "saved"].includes(input.mode)) || + !acceptedDeadline(input.deadline) || (input.requestedBranch !== undefined && (typeof input.requestedBranch !== "string" || !input.requestedBranch.trim() || @@ -3264,6 +3353,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { const root = resolve(input.projectRoot), mode = input.mode ?? "prepare"; signal = input.signal; + deadline = input.deadline; const timeoutMs = input.timeoutMs, capturedRoute = route(), routingRoot = join(resolveGlobalHackDir(), "compose-routing"); @@ -3335,7 +3425,13 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { generationsRoot, receiptPath, signal, - timeoutMs, + get timeoutMs() { + return legacyAdoptionProbePhaseMs({ + timeoutMs, + deadline, + now: Date.now(), + }); + }, requestedBranch: input.requestedBranch, check, sourceBind, @@ -3446,7 +3542,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { ); }); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async loadPrepared(opts = {}) { @@ -3469,7 +3565,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { loaded.manifest.adoption_generation_version ); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async loadActive(opts = {}) { @@ -3497,7 +3593,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { loaded.manifest.adoption_generation_version ); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async publish(opts) { @@ -3535,7 +3631,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { await completePublication(ctx, pending, binary); }); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async rollback() { @@ -3562,7 +3658,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { await completeRollback(ctx, pending); }); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async repairPublication(opts) { @@ -3594,7 +3690,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { } }); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async withLease(opts) { @@ -3659,7 +3755,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { return value; }); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async withMutation(opts) { @@ -3669,7 +3765,7 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { async () => await mutateRetainedContainers(ctx, known, captured) ); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async withPreparationStop(opts) { @@ -3684,14 +3780,14 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { await mutateRetainedContainers(ctx, known, captured, true) ); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async recoverInterruptedLock() { try { await lock.recoverInterruptedLock(); } catch (error: unknown) { - translate(error, signal); + translate(error, signal, deadline); } }, async close() { @@ -3709,6 +3805,6 @@ export async function openLegacyComposeAdoptedGenerationStore(input: { return Object.freeze(result); } catch (error: unknown) { await Promise.all(directories.map((directory) => directory.file.close())); - translate(error, signal); + translate(error, signal, deadline); } } diff --git a/src/lib/native-config-compiler.ts b/src/lib/native-config-compiler.ts index 842be1057..568d282f5 100644 --- a/src/lib/native-config-compiler.ts +++ b/src/lib/native-config-compiler.ts @@ -61,6 +61,24 @@ export const NATIVE_CONFIG_INPUT_LIMIT = 1024 * 1024; const OUTPUT_LIMIT = 8 * 1024 * 1024; const STDERR_LIMIT = 64 * 1024; const DEFAULT_TIMEOUT_MS = 10_000; +/** Smallest per-invocation budget the compiler transport accepts. */ +export const NATIVE_CONFIG_COMPILER_MIN_TIMEOUT_MS = 1; +/** Largest per-invocation budget the compiler transport accepts. */ +export const NATIVE_CONFIG_COMPILER_MAX_TIMEOUT_MS = 60_000; + +/** + * Budget of one compiler invocation for a caller with `remainingMs` left on its + * own deadline (HACK-1458): the remaining time, clamped to the transport's + * accepted range. A caller deadline longer than the ceiling still bounds each + * invocation by the ceiling, and a nearly spent one cannot request less than + * the floor; the caller's own deadline remains its authority. + */ +export function nativeConfigCompilerTimeoutWithin(remainingMs: number): number { + return Math.min( + NATIVE_CONFIG_COMPILER_MAX_TIMEOUT_MS, + Math.max(NATIVE_CONFIG_COMPILER_MIN_TIMEOUT_MS, Math.floor(remainingMs)) + ); +} const HASH_PATTERN = /^[a-f0-9]{64}$/; const OVERLAY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const RESOLVE_REQUEST_LIMIT = 20 * 1024 * 1024; @@ -843,7 +861,11 @@ function compilerRequest(opts: { throw failure("E_COMPILER_PATH", "Compiler path must be absolute."); } const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs < NATIVE_CONFIG_COMPILER_MIN_TIMEOUT_MS || + timeoutMs > NATIVE_CONFIG_COMPILER_MAX_TIMEOUT_MS + ) { throw failure( "E_COMPILER_BUDGET", "Compiler timeout is outside the supported budget." diff --git a/src/lib/native-execution-inputs.ts b/src/lib/native-execution-inputs.ts index 730b53e9c..b066ccbf6 100644 --- a/src/lib/native-execution-inputs.ts +++ b/src/lib/native-execution-inputs.ts @@ -89,6 +89,12 @@ type NativeExecutionInputSelection = { */ readonly runtimeMetadata?: NativeAuthoredRuntimeMetadataSelection; readonly signal?: AbortSignal; + /** + * Budget of each compiler invocation, including the later freshness and + * value checks of the returned inputs; read again for every invocation. + * Undefined keeps the compiler's own default (HACK-1458). + */ + readonly compilerTimeoutMs?: () => number | undefined; }; function projectName(plan: Readonly>): string { @@ -126,6 +132,7 @@ async function acquireInputs(input: { explicitDomain: opts.explicitDomain, compilerBranch: opts.compilerBranch, signal, + compilerTimeoutMs: opts.compilerTimeoutMs, }; const prepare = () => prepareNativeProjectSelection({ ...selection, requireEnvPlanning: true }); diff --git a/src/lib/native-project-validation.ts b/src/lib/native-project-validation.ts index d98d98fb4..e8bfae783 100644 --- a/src/lib/native-project-validation.ts +++ b/src/lib/native-project-validation.ts @@ -32,6 +32,12 @@ export type NativeProjectSelection = { /** Exact native source namespace context; supplied branches never trigger implicit Git/domain discovery. */ readonly compilerBranch?: string; readonly signal?: AbortSignal; + /** + * Budget of each compiler invocation, read again for every invocation so a + * caller can bound them by its remaining deadline (HACK-1458). Undefined, or + * a getter returning undefined, keeps the compiler's own default. + */ + readonly compilerTimeoutMs?: () => number | undefined; }; /** Offline project selection; local policy is read only after Rust validates it. */ @@ -117,6 +123,7 @@ export async function planPreparedNativeProject(opts: { } const result = await planNativeConfig({ ...prepared.selection, + timeoutMs: prepared.selection.compilerTimeoutMs?.(), signal: opts.signal, input: prepared.input, ...prepared.locals, @@ -195,6 +202,7 @@ export async function prepareNativeProjectSelection( const compiled = await compileNativeConfig({ input: project.input, profiles: opts.profiles, + timeoutMs: opts.compilerTimeoutMs?.(), signal: opts.signal, requireLocalResolution: true, requireEnvPlanning: opts.requireEnvPlanning, @@ -260,6 +268,7 @@ export async function prepareNativeProjectSelection( }; let result = await resolveNativeConfig({ ...resolveInputs, + timeoutMs: opts.compilerTimeoutMs?.(), probeRoutingInputs: opts.compilerBranch === undefined, }); checkResolvedIdentity(result); @@ -275,6 +284,7 @@ export async function prepareNativeProjectSelection( result = await resolveNativeConfig({ ...resolveInputs, ...routingInputs, + timeoutMs: opts.compilerTimeoutMs?.(), requireRoutingPlanning: true, }); } diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 28c68b2a2..45564c5de 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -2043,3 +2043,272 @@ for (const [httpsPort, branch, url] of [ } ); } + +/** + * Just past the compiler transport's fixed 10 s per-invocation default, which + * authored compiler calls used to keep whatever time their caller had left. + */ +const SLOW_COMPILE_MS = 10_500; + +/** + * A wrapper around the fixture compiler whose first invocation of `operation` + * (any operation when omitted) sleeps {@link SLOW_COMPILE_MS} before it runs. + * `install` selects it; the case guard restores the fixture's compiler key. + * `slept` is the marker the wrapper writes when it has slept. + */ +async function slowCompilerOnce(opts: { + readonly root: string; + readonly operation?: string; +}): Promise<{ readonly install: () => void; readonly slept: string }> { + const compiler = process.env.HACK_CONFIG_COMPILER_BINARY; + if (!compiler) { + throw new Error("missing fixture compiler"); + } + const slept = join(opts.root, "slow-compile-once"); + const wrapper = join(opts.root, "slow-compiler"); + const selected = opts.operation + ? `process.argv[2]===${JSON.stringify(opts.operation)}` + : "true"; + await writeFile( + wrapper, + `#!${process.execPath} +import {existsSync,writeFileSync} from "node:fs"; +if(${selected}&&!existsSync(${JSON.stringify(slept)})){writeFileSync(${JSON.stringify(slept)},"");await Bun.sleep(${SLOW_COMPILE_MS});} +const child=Bun.spawn([${JSON.stringify(compiler)},...process.argv.slice(2)],{stdin:"inherit",stdout:"inherit",stderr:"inherit"}); +process.exit(await child.exited); +` + ); + await chmod(wrapper, 0o700); + return { + install: () => { + process.env.HACK_CONFIG_COMPILER_BINARY = wrapper; + }, + slept, + }; +} + +macTest( + "HACK-1458: a startup compiler call may outlast the compiler's 10 s default while the startup deadline remains", + async () => { + const selected = await fixture({ noManaged: true }); + const slow = await slowCompilerOnce({ + root: selected.root, + operation: "compile", + }); + slow.install(); + expect( + await serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: STARTUP_TIMEOUT_MS, + }) + ).toBe(0); + expect(await Bun.file(slow.slept).exists()).toBe(true); + expect(await artifacts(selected.scope)).toEqual([]); + } +); + +// HACK-1458: the loaded hang of "Source5 cancellation after readiness". The +// cancel's down.before acquisition kept the compiler's fixed 10 s default, so +// a slow compiler failed the graceful stop, and the start then retained the +// live owner and host processes until a hard cancellation that never came. +macTest( + "HACK-1458: Source5 cancellation after readiness settles when its down acquisition compiler is slow", + async () => { + const selected = await fixture({ finiteHooks: true, hostProcesses: true }); + const slow = await slowCompilerOnce({ root: selected.root }); + const signal = new AbortController(); + const stages: string[] = []; + expect( + await serveNativeAuthoredProject({ + ...selected, + run, + signal: signal.signal, + startupTimeoutMs: STARTUP_TIMEOUT_MS, + onHookDiagnostic: (event) => { + stages.push(`${event.phase}:${event.boundary}`); + }, + onReady: () => { + // The next compiler invocation is the cancel's down acquisition. + slow.install(); + signal.abort(); + return undefined; + }, + }) + ).toBe(0); + expect(await Bun.file(slow.slept).exists()).toBe(true); + expect(stages).not.toContain("down.before:stop-operation"); + expect(stages).toContain("down.after:complete"); + expect( + await Bun.file(join(selected.scope.projectRoot, "process-order")).text() + ).toBe("started\nstopped\n"); + expect( + await Bun.file(join(selected.scope.projectRoot, "hook-order")).text() + ).toBe("up-before\nup-after\ndown-before\ndown-after\n"); + const files = await artifacts(selected.scope); + expect( + files.some((name) => name.endsWith(".host-process-owner.json")) + ).toBe(false); + expect(files.some((name) => name.endsWith(".hooks.json"))).toBe(false); + } +); + +/** + * Wrap the fixture's native stand-in so its second `frontend-plan` request + * (the one after up.before has run and the host processes have started) + * publishes `held` and then waits until the start kills it. + */ +async function holdSecondFrontendPlan(opts: { + readonly root: string; + readonly binary: string; +}): Promise<{ readonly binary: string; readonly held: string }> { + const plans = join(opts.root, "frontend-plans"); + const held = join(opts.root, "second-frontend-plan-held"); + const binary = join(opts.root, "native-hold-second-plan"); + await writeFile( + binary, + `#!${process.execPath} +import {appendFileSync,readFileSync,writeFileSync} from "node:fs"; +const args=process.argv.slice(2); +if(args.includes("frontend-plan")){appendFileSync(${JSON.stringify(plans)},"plan\\n");if(readFileSync(${JSON.stringify(plans)},"utf8").trim().split("\\n").length===2){writeFileSync(${JSON.stringify(held)},String(process.pid));setInterval(()=>{},1000);await new Promise(()=>{});}} +const child=Bun.spawn([${JSON.stringify(opts.binary)},...args],{stdin:"inherit",stdout:"inherit",stderr:"inherit"}); +process.exit(await child.exited); +` + ); + await chmod(binary, 0o700); + return { binary, held }; +} + +// HACK-1458: a cancel (or the startup deadline, which aborts the same signal) +// after the host processes started and before the startup intent exists must +// stop them. Their teardown freshness check used the startup inputs, bound to +// the aborted startup signal, so it always refused and left the groups running. +macTest( + "HACK-1458: cancellation after host processes start and before reservation stops them", + async () => { + const selected = await fixture({ finiteHooks: true, hostProcesses: true }); + const hold = await holdSecondFrontendPlan({ + root: selected.root, + binary: selected.runtime.binary, + }); + const signal = new AbortController(); + let settled = false; + const pending = serveNativeAuthoredProject({ + ...selected, + runtime: { ...selected.runtime, binary: hold.binary }, + run, + signal: signal.signal, + startupTimeoutMs: STARTUP_TIMEOUT_MS, + }) + .catch((error: unknown) => error) + .finally(() => { + settled = true; + }); + while (!(settled || (await Bun.file(hold.held).exists()))) { + await Bun.sleep(20); + } + expect(await Bun.file(hold.held).exists()).toBe(true); + signal.abort(); + const error = await pending; + expect(error).toBeInstanceOf(NativeAuthoredProjectStartError); + expect(error).toHaveProperty("canceled", true); + expect( + await Bun.file(join(selected.scope.projectRoot, "process-order")).text() + ).toBe("started\nstopped\n"); + const files = await artifacts(selected.scope); + expect(files.some((name) => name.endsWith(".start.json"))).toBe(false); + expect( + files.some((name) => name.endsWith(".host-process-owner.json")) + ).toBe(false); + } +); + +/** + * Delay the stand-in mux's first `list-panes` (issued right after it started + * the host process window) and publish `held` first, so a case can cancel + * while the host process launch is still in progress. + */ +async function holdFirstPaneQuery(root: string): Promise { + const bin = join(root, "bin"); + const held = join(root, "pane-query-held"); + await writeFile( + join(bin, "tmux-stand-in"), + await readFile(join(bin, "tmux")), + { mode: 0o700 } + ); + await writeFile( + join(bin, "tmux"), + `#!${process.execPath} +import {existsSync,writeFileSync} from "node:fs"; +const args=process.argv.slice(2); +if(args[0]==="list-panes"&&!existsSync(${JSON.stringify(held)})){writeFileSync(${JSON.stringify(held)},"");await Bun.sleep(1000);} +const child=Bun.spawn([${JSON.stringify(join(bin, "tmux-stand-in"))},...args],{stdin:"inherit",stdout:"inherit",stderr:"inherit"}); +process.exit(await child.exited); +` + ); + await chmod(join(bin, "tmux"), 0o700); + return held; +} + +/** True once no process in `group` remains, within a 5 s bound. */ +async function groupEnded(group: number): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + try { + process.kill(-group, 0); + } catch (error) { + if ((error as { code?: unknown }).code === "ESRCH") { + return true; + } + throw error; + } + await Bun.sleep(50); + } + return false; +} + +// HACK-1458: a start that failed after the host process launch began but +// before its ready record left the launched group running: teardown required +// a ready record, and recovery never converts a bare launch intent into +// authority. The launch now ends what it started through its session token. +macTest( + "HACK-1458: cancellation during the host process launch ends the launched group", + async () => { + const selected = await fixture({ finiteHooks: true, hostProcesses: true }); + const held = await holdFirstPaneQuery(selected.root); + const signal = new AbortController(); + let settled = false; + const pending = serveNativeAuthoredProject({ + ...selected, + run, + signal: signal.signal, + startupTimeoutMs: STARTUP_TIMEOUT_MS, + }) + .catch((error: unknown) => error) + .finally(() => { + settled = true; + }); + while (!(settled || (await Bun.file(held).exists()))) { + await Bun.sleep(20); + } + expect(await Bun.file(held).exists()).toBe(true); + // The window exists now; record its group before the launch can end it. + const state = JSON.parse( + await readFile(join(selected.root, "mux.json"), "utf8") + ) as { readonly windows: Record }; + const groups = Object.values(state.windows).map((row) => row.pid); + expect(groups).toHaveLength(1); + signal.abort(); + const error = await pending; + expect(error).toBeInstanceOf(NativeAuthoredProjectStartError); + expect(error).toHaveProperty("canceled", true); + for (const group of groups) { + expect(await groupEnded(group)).toBe(true); + } + // The owned session was ended through its token. Its owner record stays + // unless every launched group was recorded and proven absent; a launch + // cancelled this early may not have recorded its command group yet. + expect(await Bun.file(join(selected.root, "mux.json")).exists()).toBe( + false + ); + } +); diff --git a/tests/native-compose-adoption-generation.test.ts b/tests/native-compose-adoption-generation.test.ts index bdad69630..cf2f0a18a 100644 --- a/tests/native-compose-adoption-generation.test.ts +++ b/tests/native-compose-adoption-generation.test.ts @@ -4917,3 +4917,91 @@ test("preparation stop refuses changed source, forged claims and config drift be await store.close(); } }); + +/** + * HACK-1458: public adoption dispatch derives each store inspection phase from + * the command's caller deadline. A synthetic clock jumps once, right after the + * dispatch's first engine probe, so these cases need no real waiting: the + * phase that probe opened must survive the jump while the deadline remains, + * and must end as a timeout once the deadline has passed. + * + * The case budget is a ceiling only: prepare and publish spawn hundreds of + * stub-engine probes, so a loaded host needs minutes; a passing case never + * waits on it. + */ +const PUBLIC_DEADLINE_CASE_MS = 300_000; +async function publicDispatchAfterClockJump(opts: { + readonly startupTimeoutMs: number; + readonly jumpMs: number; +}): Promise<{ readonly result: unknown; readonly jumped: boolean }> { + const { store, generation } = await prepared(); + const priorCompiler = process.env.HACK_CONFIG_COMPILER_BINARY; + const priorStartup = process.env.HACK_COMPOSE_STARTUP_TIMEOUT_MS; + const realNow = Date.now; + let clock: { mockRestore: () => void } | undefined; + let offset = 0; + try { + const binary = await compiler(); + process.env.HACK_CONFIG_COMPILER_BINARY = binary; + await store.publish({ generation, binary }); + process.env.HACK_COMPOSE_STARTUP_TIMEOUT_MS = String(opts.startupTimeoutMs); + const commands = join(root, "commands"); + const before = Bun.file(commands).size; + clock = spyOn(Date, "now").mockImplementation(() => { + if (offset === 0 && Bun.file(commands).size > before) { + offset = opts.jumpMs; + } + return realNow() + offset; + }); + const result = await tryLegacyComposeAdoptedCommand({ + cwd: projectRoot, + operation: "up", + detach: true, + }).catch((error: unknown) => error); + return { result, jumped: offset === opts.jumpMs }; + } finally { + clock?.mockRestore(); + restoreEnv("HACK_CONFIG_COMPILER_BINARY", priorCompiler); + restoreEnv("HACK_COMPOSE_STARTUP_TIMEOUT_MS", priorStartup); + await store.close(); + } +} + +boundedTest( + "HACK-1458: public dispatch inspection outlives the former fixed 15 s phase clock while the caller deadline remains", + async () => { + const { result, jumped } = await publicDispatchAfterClockJump({ + startupTimeoutMs: 120_000, + jumpMs: 16_000, + }); + expect(jumped).toBe(true); + expect(result).toBe(0); + expect(await mutationCommands()).toEqual([["container", "start", ID]]); + }, + PUBLIC_DEADLINE_CASE_MS +); + +boundedTest( + "HACK-1458: an expired public dispatch deadline ends inspection as a startup timeout, not invalid config", + async () => { + const { result, jumped } = await publicDispatchAfterClockJump({ + startupTimeoutMs: 5000, + jumpMs: 6000, + }); + expect(jumped).toBe(true); + expect(result).toBeInstanceOf(HackCliError); + if (!(result instanceof HackCliError)) { + throw new Error("missing public refusal"); + } + expect(result.code).toBe("E_STARTUP_TIMEOUT"); + // The fixed store message keeps the closed `legacy-deadline` diagnostic. + expect(result.message).toBe( + new LegacyComposeAdoptedGenerationError("E_LEGACY_ADOPTION_DEADLINE") + .message + ); + expect(result.message).not.toContain(CANARY); + expect(await mutationCommands()).toEqual([]); + expect((await readReceipt()).pendingOperation).toBeNull(); + }, + PUBLIC_DEADLINE_CASE_MS +); From eae1eac2fc4ff34f0504e6e26efc86f47c7dc903 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 16:39:47 -0400 Subject: [PATCH 2/2] fix(native): keep the busy refusal ahead of the adoption deadline translation An adopted store with a caller deadline still reports a held lock as E_LEGACY_ADOPTION_BUSY; only an otherwise unclassified failure becomes the deadline refusal (HACK-1458). Co-Authored-By: Claude Opus 5.5 --- src/lib/native-compose-adoption-generation.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/lib/native-compose-adoption-generation.ts b/src/lib/native-compose-adoption-generation.ts index 914c47da3..1d9d2211c 100644 --- a/src/lib/native-compose-adoption-generation.ts +++ b/src/lib/native-compose-adoption-generation.ts @@ -335,16 +335,6 @@ function translate( if (error instanceof LegacyComposeAdoptedGenerationError) { throw error; } - if (deadlineExpiry(error, deadline)) { - const expired = new LegacyComposeAdoptedGenerationError( - "E_LEGACY_ADOPTION_DEADLINE" - ); - const diagnostic = legacyComposeOrderedRefusal(error); - if (diagnostic) { - attachLegacyComposeOrderedRefusal(expired, diagnostic); - } - throw expired; - } if ( error instanceof NativeComposeGenerationError && error.code === "E_NATIVE_COMPOSE_BUSY" @@ -352,7 +342,9 @@ function translate( refuse("E_LEGACY_ADOPTION_BUSY"); } const translated = new LegacyComposeAdoptedGenerationError( - "E_LEGACY_ADOPTION_STATE" + deadlineExpiry(error, deadline) + ? "E_LEGACY_ADOPTION_DEADLINE" + : "E_LEGACY_ADOPTION_STATE" ); const diagnostic = legacyComposeOrderedRefusal(error); if (diagnostic) {