Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/reference/native-authored-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/reference/native-compose-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
184 changes: 120 additions & 64 deletions src/backends/native-authored-host-processes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ async function unchanged(saved: Awaited<ReturnType<typeof capture>>) {
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);
Expand All @@ -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<void> {
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;
Expand Down Expand Up @@ -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<ReturnType<typeof capture>> | undefined;
let stateFile: Awaited<ReturnType<typeof capture>> | undefined;
Expand All @@ -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<number>();
const deliveries = new Set<
Awaited<ReturnType<typeof serveLifecycleProcessDelivery>>
>();
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down
44 changes: 42 additions & 2 deletions src/backends/native-authored-project-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>;
}): 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 };
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -947,6 +966,7 @@ export async function serveNativeAuthoredProject(
let nativeCauseCode: string | undefined;
let hookOwner: NativeAuthoredHookOwner | undefined;
let hostProcesses: NativeAuthoredHostProcesses | undefined;
let teardownInputs: (() => Promise<void>) | undefined;
let storageTool: NativeAuthoredStorageTool | undefined;
let hookStop:
| { readonly close: (force?: boolean) => Promise<void> }
Expand Down Expand Up @@ -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)) {
Expand All @@ -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,
Expand Down Expand Up @@ -1183,6 +1222,7 @@ export async function serveNativeAuthoredProject(
processes: hostProcesses,
attempt,
outcome,
refresh: teardownInputs,
});
hostProcesses = host.processes;
outcome = host.outcome;
Expand Down
Loading
Loading