Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
7 changes: 6 additions & 1 deletion docs/reference/native-compose-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ are never diagnostic fields. Copied error properties do not acquire provenance.
The diagnostic does not grant recovery, clear intent or change the original error
code, checks, child lifetime or deadline. Missing detail leaves the cause unknown;
returned incomplete outcomes retain their existing reporting contract. Historical
failures without this detail remain unexplained.
failures without this detail remain unexplained. An unclassified native failure
(fixed `E_COMPOSE_FAILED` text, for example a storage-carrier refusal during
`exec`) carries the same detail when its owner issued one. Without `--json`, the
CLI prints the same pair after the error line as
`Refusal boundary: <stage> (<reason>); values omitted.`; detail with any other
shape prints nothing extra.

`publishRunProjection` derives a one-off document from the verified saved artifact
inside the mutation lock. It accepts a target service, never caller-supplied JSON.
Expand Down
14 changes: 10 additions & 4 deletions docs/reference/native-compose-storage-xattr-carrier.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@ publishes and synchronizes that identity, then repeats identity, boot, executabl
and admission checks before a one-use continuation of the captured child. A
missing or failed publication cancels that original invocation; an armed record
alone proves neither spawn nor absence. The command's original budget includes
the handshake. No guessed PID or retry is used to rescue a fast command.
the handshake, and each process identity read shares that remaining budget rather
than a tighter fixed bound. No guessed PID or retry is used to rescue a fast command.
Live handshake and settlement budgets use a captured monotonic deadline; the
saved wall deadline is a record field only. Executable device/inode identities
use exact decimal strings from bigint metadata, including sealed macOS binaries.
Expand All @@ -392,9 +393,14 @@ The carrier uses held regular capture files, not pipes. Version 2 therefore name
its capture evidence `held-files-quiescent`, never EOF. The original exit callback,
strict ESRCH group absence, held file/path identity, both synchronized bounded
captures and their hashes precede settlement publication. EPERM/EIO and unknown
exit or capture disposition refuse. Settlement publication shares the existing
three-second post-command group-observation budget; timeout/cancellation remain
refused results and cannot grant a subsequent daemon command.
exit or capture disposition refuse. Settlement publication shares one finite
15-second post-command budget with group observation and its identity reads;
timeout/cancellation remain refused results and cannot grant a subsequent daemon
command. These budgets must outlast an ordinary multi-second host stall: a stalled
owner resumes with its timer already due, and a refusal after the daemon effect
leaves durable uncertainty that only explicit recovery can report. Earlier
one-second identity reads and a three-second settlement budget refused healthy
commands that way on loaded hosts.

Only the original in-memory writer can advance its exact sequence. Publication
failure or deadline permanently invalidates that writer and retains its private
Expand Down
10 changes: 10 additions & 0 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,16 @@ export async function handleRunCliError(opts: {

if (error instanceof HackCliError) {
logger.error({ message: `${error.code}: ${error.message}` });
if (error.detail !== undefined) {
// Loaded only for a detailed error, off the ordinary CLI start path.
const { nativeComposeEffectRefusalSummary } = await import(
"../lib/native-compose-effect-diagnostics.ts"
);
const summary = nativeComposeEffectRefusalSummary(error.detail);
if (summary) {
logger.error({ message: summary });
}
}
logDebugStack(error);
return 1;
}
Expand Down
6 changes: 6 additions & 0 deletions src/lib/native-compose-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2466,9 +2466,15 @@ function throwNativeComposeCommandError(error: unknown): never {
if (error instanceof HackCliError || error instanceof CliUsageError) {
throw error;
}
// An unclassified failure (for example a storage-carrier refusal) keeps the
// closed boundary its owner issued; the code and fixed text are unchanged.
const diagnostic = nativeComposeEffectRefusal(error);
throw new HackCliError({
code: "E_COMPOSE_FAILED",
message:
"Native Compose operation failed; saved ownership and persistent data are retained. Values omitted.",
...(diagnostic
? { detail: { native_compose_effect_refusal: diagnostic } }
: {}),
});
}
32 changes: 32 additions & 0 deletions src/lib/native-compose-effect-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,38 @@ export function nativeComposeEffectReason(
return "unclassified";
}

function ownData(value: unknown, key: string): unknown {
if (typeof value !== "object" || value === null) {
return undefined;
}
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor && Object.hasOwn(descriptor, "value")
? descriptor.value
: undefined;
}

/**
* One fixed human line for the closed boundary carried in public CLI error
* detail, so a command without `--json` (such as `exec`) still names it.
* Anything but a known stage and reason pair yields null; accessors are not read.
*/
export function nativeComposeEffectRefusalSummary(
detail: unknown
): string | null {
try {
const refusal = ownData(detail, "native_compose_effect_refusal");
const stage = stages.find((value) => value === ownData(refusal, "stage"));
const reason = reasons.find(
(value) => value === ownData(refusal, "reason")
);
return stage && reason
? `Refusal boundary: ${stage} (${reason}); values omitted.`
: null;
} catch {
return null;
}
}

/** Keep the original issued boundary through a deliberate fixed-code translation. */
export function copyNativeComposeEffectRefusal(
source: unknown,
Expand Down
99 changes: 80 additions & 19 deletions src/lib/native-compose-storage-witness-docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ const PROCESS =
const TICKS = /^[1-9][0-9]*$/;
const BOOT = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/;
const CAPTURE_LIMIT = 4096;
/**
* Post-exit settlement budget of a recorded command (group absence, boot and
* executable identity, capture sync and settlement publication). It and the
* identity reads must outlast an ordinary multi-second host stall: a stalled
* owner resumes with its timer already due, so a sub-second or three-second
* bound refused healthy commands after their daemon effect and left durable
* uncertainty (HACK-1281). It stays finite and grants nothing.
*/
const SETTLEMENT_BUDGET_MS = 15_000;
// A late/unconfirmed publication cannot release its original held directory FDs.
const unsettledCommandFiles = new Set<readonly HeldDirectory[]>();
type Selection = { readonly name: string; readonly storage: string };
Expand Down Expand Up @@ -810,7 +819,7 @@ async function groupAbsent(
return refuse();
}
if (now() >= deadline) {
return refuse();
return refuseCommand("probe-budget");
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
Expand Down Expand Up @@ -903,6 +912,42 @@ async function commandBoot(
}
/** No callback can reject before the shared owner awaits exit. A rejected owner or
* unproven group retains the journal; daemon effects are never inferred from exit. */
type StorageCommandRefusal = "probe-timeout" | "probe-cancel" | "probe-budget";
const storageCommandRefusals = new WeakMap<object, StorageCommandRefusal>();
/** The same fixed refusal, keeping only its closed command cause (a timed-out or
* cancelled child, or an exhausted publication/settlement budget) for the effect
* diagnostic. It grants nothing and changes no check, deadline or retention. */
function refuseCommand(reason: StorageCommandRefusal | undefined): never {
const error = new Error(
"Native storage xattr proof refused; values omitted."
);
if (reason) {
storageCommandRefusals.set(error, reason);
}
throw error;
}
function storageCommandRefusal(
error: unknown
): StorageCommandRefusal | undefined {
return typeof error === "object" && error !== null
? storageCommandRefusals.get(error)
: undefined;
}
function exitRefusal(
exit: RunExitEvent | null
): StorageCommandRefusal | undefined {
if (exit?.timedOut) {
return "probe-timeout";
}
return exit?.cancelled ? "probe-cancel" : undefined;
}
/** Effect reason for a carrier boundary: a classified command cause first. */
export function nativeComposeStorageCarrierReason(
error: unknown
): NativeComposeEffectRefusal["reason"] {
return storageCommandRefusal(error) ?? nativeComposeEffectReason(error);
}

export async function runNativeComposeStorageDockerCommand(supplied: {
readonly context: Pick<Context, "signal" | "deadline">;
readonly files: Pick<
Expand Down Expand Up @@ -958,30 +1003,39 @@ export async function runNativeComposeStorageDockerCommand(supplied: {
exit: RunExitEvent | null = null;
let armed: NativeComposeStorageArmedCommand | null = null;
let publicationRefused = false;
let publicationCause: StorageCommandRefusal | undefined;
const cancellation = new AbortController();
try {
await opts.assertAdmitted();
const timeoutMs = opts.cleanup
? opts.timeoutMs
: Math.min(opts.timeoutMs, opts.context.deadline - Date.now());
if (!(timeoutMs > 0)) {
return refuse();
return refuseCommand("probe-budget");
}
const deadline = Date.now() + timeoutMs;
let boundaryDeadline = performance.now() + timeoutMs;
let recordingSettlement = false;
const record = opts.originalCommand;
const guard = () => {
if (performance.now() >= boundaryDeadline) {
return refuseCommand("probe-budget");
}
if (publicationRefused) {
return refuseCommand(publicationCause);
}
if (
performance.now() >= boundaryDeadline ||
publicationRefused ||
(!(recordingSettlement || opts.cleanup) && opts.context.signal.aborted)
!(recordingSettlement || opts.cleanup) &&
opts.context.signal.aborted
) {
return refuse();
return refuseCommand("probe-cancel");
}
};
const bounded = async <T>(work: Promise<T>): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
// An exhausted budget refuses below before the race observes the work;
// its own rejection must not escape as an unhandled process error.
work.catch(() => undefined);
try {
guard();
const result = await Promise.race([
Expand All @@ -990,6 +1044,7 @@ export async function runNativeComposeStorageDockerCommand(supplied: {
timer = setTimeout(
() => {
publicationRefused = true;
publicationCause ??= "probe-budget";
if (record) {
invalidateNativeComposeStorageCommandOwner(record.owner);
}
Expand All @@ -1006,13 +1061,14 @@ export async function runNativeComposeStorageDockerCommand(supplied: {
]);
guard();
return result;
} catch {
} catch (error) {
publicationRefused = true;
publicationCause ??= storageCommandRefusal(error);
if (record) {
invalidateNativeComposeStorageCommandOwner(record.owner);
}
cancellation.abort();
return refuse();
return refuseCommand(publicationCause);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
Expand All @@ -1036,7 +1092,8 @@ export async function runNativeComposeStorageDockerCommand(supplied: {
guard();
return Promise.resolve();
},
timeoutMs: Math.min(1000, boundaryDeadline - performance.now()),
// Shares the enclosing handshake or settlement budget; no tighter bound.
timeoutMs: boundaryDeadline - performance.now(),
});
if (result.exitCode !== 0) {
return refuse();
Expand Down Expand Up @@ -1156,8 +1213,9 @@ export async function runNativeComposeStorageDockerCommand(supplied: {
if (!event.resumeSuspended?.()) {
return refuse();
}
} catch {
} catch (error) {
publicationRefused = true;
publicationCause ??= storageCommandRefusal(error);
invalidateNativeComposeStorageCommandOwner(record.owner);
cancellation.abort();
}
Expand All @@ -1168,12 +1226,15 @@ export async function runNativeComposeStorageDockerCommand(supplied: {
},
}
);
boundaryDeadline = performance.now() + 3000;
boundaryDeadline = performance.now() + SETTLEMENT_BUDGET_MS;
recordingSettlement = true;
await groupAbsent(pid, record ? boundaryDeadline : undefined);
settled = true;
if (record) {
if (publicationRefused || !armed || !exit) {
if (publicationRefused) {
return refuseCommand(publicationCause);
}
if (!(armed && exit)) {
return refuse();
}
if (
Expand Down Expand Up @@ -1208,13 +1269,13 @@ export async function runNativeComposeStorageDockerCommand(supplied: {
!(exit as RunExitEvent).cancelled
)
) {
return refuse();
return refuseCommand(exitRefusal(exit));
}
return { exitCode: code, stdout: io.read().stdout };
} catch (error) {
if (opts.originalCommand) {
invalidateNativeComposeStorageCommandOwner(opts.originalCommand.owner);
return refuse();
return refuseCommand(storageCommandRefusal(error));
}
throw error;
} finally {
Expand Down Expand Up @@ -1334,7 +1395,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(
} catch (error) {
retainNativeComposeEffectRefusal(error, {
stage,
reason: nativeComposeEffectReason(error),
reason: nativeComposeStorageCarrierReason(error),
});
throw error;
}
Expand Down Expand Up @@ -1375,7 +1436,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(
} catch (error) {
retainNativeComposeEffectRefusal(error, {
stage: "storage-target",
reason: nativeComposeEffectReason(error),
reason: nativeComposeStorageCarrierReason(error),
});
throw error;
}
Expand Down Expand Up @@ -1535,7 +1596,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(
} catch (error) {
retainNativeComposeEffectRefusal(error, {
stage,
reason: nativeComposeEffectReason(error),
reason: nativeComposeStorageCarrierReason(error),
});
throw error;
}
Expand Down Expand Up @@ -1689,7 +1750,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(
} catch (error) {
retainNativeComposeEffectRefusal(error, {
stage: "storage-helper-policy",
reason: nativeComposeEffectReason(error),
reason: nativeComposeStorageCarrierReason(error),
});
throw error;
}
Expand Down Expand Up @@ -2026,7 +2087,7 @@ export async function createNativeComposeDockerStorageXattrCarrier(
} catch (error) {
retainNativeComposeEffectRefusal(error, {
stage,
reason: nativeComposeEffectReason(error),
reason: nativeComposeStorageCarrierReason(error),
});
throw error;
}
Expand Down
Loading
Loading