Skip to content
Merged
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
14 changes: 14 additions & 0 deletions docs/reference/native-authored-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ 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. If the graph fails
before Ready after reserving the startup intent, host teardown additionally requires
the original producer and readers to settle and the original graph group to be
absent. Startup reacquires unchanged host inputs under the hard-stop signal, then
authenticates an exact Removed receipt and absent resource observations before
stopping the owned host session. A live, foreign, unknown or unreserved receipt,
changed input, or hard cancellation keeps this attempt 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
9 changes: 8 additions & 1 deletion docs/reference/native-compose-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,14 @@ 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. Publication file moves, rollback
restoration and scoped routing revalidation retain that live remaining budget;
copying their operation context cannot restore time already spent. 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
Loading
Loading