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
404 changes: 404 additions & 0 deletions src/backends/native-authored-artifact-error.ts

Large diffs are not rendered by default.

19 changes: 14 additions & 5 deletions src/backends/native-authored-project-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
type NativeEnvMetadata,
parseNativeEnvMetadata,
} from "../lib/native-env-plan-protocol.ts";
import {
type NativeAuthoredArtifactPhase,
nativeAuthoredArtifactError,
} from "./native-authored-artifact-error.ts";
import {
type NativeAuthoredReceipt,
type NativeAuthoredReview,
Expand Down Expand Up @@ -362,6 +366,7 @@ async function storage<T>(
branch: input.branch,
};
const held: HeldDirectory[] = [];
let phase: NativeAuthoredArtifactPhase = "scope";
try {
const projectRoot = await realpath(opts.projectRoot);
const nativeHome = await realpath(opts.nativeHome);
Expand All @@ -373,6 +378,7 @@ async function storage<T>(
) {
return refused();
}
phase = "directories";
for (const path of [projectRoot, opts.projectDir, nativeHome]) {
held.push(await holdDirectory(path, false));
}
Expand All @@ -383,13 +389,15 @@ async function storage<T>(
branch: opts.branch,
directories: held.map(({ info }) => ({ dev: info.dev, ino: info.ino })),
};
phase = "private-store";
for (const [path, privateRoot] of [
[join(opts.projectDir, ".internal"), false],
[join(opts.projectDir, ".internal", "native-authored-runs"), true],
] as const) {
const directory = await storeDirectory(path, create, privateRoot);
if (!directory) {
await recheckDirectories(held);
phase = "action";
return await action(undefined, async () => {
await recheckDirectories(held);
await absent(path);
Expand All @@ -412,6 +420,7 @@ async function storage<T>(
check,
});
await check();
phase = "action";
return await action(
{
identity,
Expand All @@ -438,7 +447,7 @@ async function storage<T>(
) {
throw error;
}
return refused();
throw nativeAuthoredArtifactError(error, phase);
} finally {
await Promise.all(held.map(({ file }) => file.close()));
}
Expand Down Expand Up @@ -1032,8 +1041,8 @@ export async function withNativeAuthoredProjectAdmission<T>(
}
try {
await verifyLock();
} catch {
return refused();
} catch (error) {
throw nativeAuthoredArtifactError(error, "action");
}
};
const withMutation = async <R>(
Expand All @@ -1048,8 +1057,8 @@ export async function withNativeAuthoredProjectAdmission<T>(
await assertHeld();
});
});
} catch {
return refused();
} catch (error) {
throw nativeAuthoredArtifactError(error, "action");
}
};
const loadStart = async () => {
Expand Down
55 changes: 48 additions & 7 deletions src/lib/native-compose-private-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,14 @@ function requireInterruptedSelection(
}
}

async function inspection(
command: readonly string[]
): Promise<{ readonly output: string; readonly code: number }> {
async function inspection(command: readonly string[]): Promise<{
readonly output: string;
readonly code: number;
readonly elapsedMs: number;
readonly timedOut: boolean;
}> {
const started = performance.now();
let timedOut = false;
const child = Bun.spawn([...command], {
stdin: "ignore",
stdout: "pipe",
Expand All @@ -158,7 +163,10 @@ async function inspection(
LC_ALL: "C",
},
});
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, 3000);
const observeCpu = beginNativeCpuChild(child, "other");
try {
const chunks: Uint8Array[] = [];
Expand All @@ -185,6 +193,8 @@ async function inspection(
.decode(Buffer.concat(chunks))
.trim(),
code: await child.exited,
elapsedMs: Math.ceil(performance.now() - started),
timedOut,
};
} finally {
clearTimeout(timer);
Expand All @@ -202,8 +212,12 @@ async function bootId(): Promise<string> {
"-n",
"kern.bootsessionuuid",
]);
if (result.code !== 0) {
refuse();
if (result.code !== 0 || result.timedOut) {
throw new NativeComposeInspectionError({
observation: "boot-session",
outcome: result.timedOut ? "timeout" : "failed",
elapsedMs: result.elapsedMs,
});
}
value = result.output.toLowerCase();
} else if (process.platform === "linux") {
Expand Down Expand Up @@ -255,6 +269,13 @@ async function processBirth(
"-o",
"uid=,lstart=",
]);
if (result.timedOut) {
throw new NativeComposeInspectionError({
observation: "process-birth",
outcome: "timeout",
elapsedMs: result.elapsedMs,
});
}
if (result.code === 1 && result.output === "") {
return null;
}
Expand All @@ -268,7 +289,11 @@ async function processBirth(
birth === undefined ||
!BIRTH.test(birth)
) {
refuse();
throw new NativeComposeInspectionError({
observation: "process-birth",
outcome: result.code === 0 ? "malformed" : "failed",
elapsedMs: result.elapsedMs,
});
}
return { uid, birth };
}
Expand Down Expand Up @@ -371,6 +396,22 @@ export class NativeComposeGenerationError extends Error {
this.code = code;
}
}
/** Closed observation details only: neither host-tool output nor process identities escape. */
export class NativeComposeInspectionError extends NativeComposeGenerationError {
readonly observation: "boot-session" | "process-birth";
readonly outcome: "timeout" | "failed" | "malformed";
readonly elapsedMs: number;
constructor(opts: {
readonly observation: "boot-session" | "process-birth";
readonly outcome: "timeout" | "failed" | "malformed";
readonly elapsedMs: number;
}) {
super("E_NATIVE_COMPOSE_STATE");
this.observation = opts.observation;
this.outcome = opts.outcome;
this.elapsedMs = opts.elapsedMs;
}
}
function refuse(): never {
throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_STATE");
}
Expand Down
19 changes: 19 additions & 0 deletions src/lib/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export interface ExecOptions {
* held in memory.
*/
readonly maxStdoutBytes?: number;
/** Passive scalar timing only; observer failures never change command results. */
readonly onTiming?: (sample: {
readonly elapsedMs: number;
readonly timedOut: boolean;
readonly exitCode: number;
}) => void;
}

/** Exit code `exec` reports when `maxStdoutBytes` was exceeded. */
Expand Down Expand Up @@ -72,6 +78,7 @@ export async function exec(
if (timeoutMs !== undefined && timeoutMs <= 0) {
return { exitCode: 124, stdout: "", stderr: "" };
}
const started = performance.now();
const proc = Bun.spawn([...cmd], {
cwd: opts.cwd,
env: buildSpawnEnv(opts.env, opts.unsetEnvKeys),
Expand Down Expand Up @@ -116,6 +123,18 @@ export async function exec(
const exitCode = await proc.exited;
observeCpu(exitCode);
timeout.dispose();
try {
const observed: unknown = opts.onTiming?.(
Object.freeze({
elapsedMs: Math.max(0, performance.now() - started),
timedOut: timeout.didTimeout(),
exitCode,
})
);
void Promise.resolve(observed).catch(() => undefined);
} catch {
// Optional diagnostics cannot replace a command's exit or timeout outcome.
}

if (overflowed) {
return {
Expand Down
69 changes: 69 additions & 0 deletions src/mux/mux-backend.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,57 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { type ExecOptions, type ExecResult, exec } from "../lib/shell.ts";

const MUX_OPERATIONS = [
"list-sessions",
"has-session",
"new-session",
"new-window",
"kill-session",
"list-panes",
"list-windows",
"show-options",
"set-option",
"send-keys",
"start-server",
] as const;
type MuxCommandOperation = (typeof MUX_OPERATIONS)[number] | "other";

/** Labels are closed even when the command itself is transported over stdin. */
export function muxCommandOperation(
value: string | undefined
): MuxCommandOperation {
return MUX_OPERATIONS.find((operation) => operation === value) ?? "other";
}
export type MuxCommandSample = Readonly<{
backend: "tmux" | "zellij" | "other";
operation: MuxCommandOperation;
phase: "started" | "finished";
elapsedMs: number;
timedOut: boolean;
exitCode: number | null;
}>;
const commandObservers = new AsyncLocalStorage<
(sample: MuxCommandSample) => void
>();

/** Scope value-free mux timings to one asynchronous caller; no global observer or command arguments. */
export function observeMuxCommands<T>(opts: {
readonly onSample: (sample: MuxCommandSample) => void;
readonly action: () => T;
}): T {
return commandObservers.run(opts.onSample, opts.action);
}
function recordCommand(sample: MuxCommandSample): void {
try {
const observed: unknown = commandObservers.getStore()?.(
Object.freeze(sample)
);
void Promise.resolve(observed).catch(() => undefined);
} catch {
// Diagnostic callbacks are passive, including when their caller has failed.
}
}

/** Interactive workspaces use tmux; zellij remains a legacy lifecycle-state tag until migration. */
export type MuxBackendName = "tmux" | "zellij";

Expand Down Expand Up @@ -60,12 +112,29 @@ export async function runMuxCommand(
command: readonly string[],
opts: Omit<ExecOptions, "timeoutMs"> & {
readonly timeoutMs?: number;
readonly diagnosticOperation?: MuxCommandOperation;
} = {}
): Promise<ExecResult> {
const backend =
command[0] === "tmux" || command[0] === "zellij" ? command[0] : "other";
const selected = command[1] === "-u" ? command[2] : command[1];
const operation = muxCommandOperation(opts.diagnosticOperation ?? selected);
recordCommand({
backend,
operation,
phase: "started",
elapsedMs: 0,
timedOut: false,
exitCode: null,
});
const result = await exec(command, {
...opts,
stdin: opts.stdin instanceof Blob ? opts.stdin : "ignore",
timeoutMs: opts.timeoutMs ?? muxCommandTimeoutMs(),
onTiming: (sample) => {
recordCommand({ backend, operation, phase: "finished", ...sample });
return opts.onTiming?.(sample);
},
});
if (result.exitCode === 124) {
throw new MuxCommandTimeoutError(command);
Expand Down
2 changes: 2 additions & 0 deletions src/mux/tmux-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MuxCommandTimeoutError,
type MuxSession,
type MuxSessionCreateResult,
muxCommandOperation,
runMuxCommand,
} from "./mux-backend.ts";
import { stageMuxEnvironment } from "./private-environment.ts";
Expand Down Expand Up @@ -49,6 +50,7 @@ async function execTmuxSource(
["tmux", "-u", "start-server", ";", "source-file", "-"],
{
stdin: new Blob([`${args.map(tmuxQuote).join(" ")}\n`]),
diagnosticOperation: muxCommandOperation(args[0]),
}
);
if (result.exitCode === 0) {
Expand Down
Loading
Loading