Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,7 @@ jobs:
tests/boundary/process/directAnalysisSignals.test.ts
tests/boundary/process/processCaptureLifecycle.test.ts
tests/boundary/process/processCli.test.ts
tests/boundary/process/nativeUiVerifierFixtureOwnership.test.ts
tests/boundary/mcp/sessionMcp.test.ts

reconstruction-readiness:
Expand Down
6 changes: 5 additions & 1 deletion docs/testing-lanes.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,11 @@ conformance/vtable fixture.
allows a host-permission-boundary-only result and explicitly reports
`positive_e2e: false`; it must not be reported as capture/action proof.
Both commands reject a changed executable digest and clean up the fixture
process and helper. These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md).
process and helper. LaunchServices starts the fixture outside `open`'s process
tree, so its launch carries a run token that cleanup sweeps with or without a
ready record. Incomplete cleanup fails the lane and retains the fixture root,
and an interrupted lane exits only after that release settles.
These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md).

`npm run verify:native-calls` needs macOS with Command Line Tools (`clang`,
`lldb`, `codesign`, `nm`) and actual debugger access to the owned fixtures.
Expand Down
3 changes: 3 additions & 0 deletions scripts/ci/scopes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ const PATH_SCOPES = {
"tests/boundary/process/directAnalysisSignals.test.ts",
"tests/boundary/process/processCaptureLifecycle.test.ts",
"tests/boundary/process/processCli.test.ts",
"tests/boundary/process/nativeUiVerifierFixtureOwnership.test.ts",
"scripts/verify-native-ui.mjs",
"scripts/lib/native-ui-fixture-lifecycle.*",
"tests/boundary/mcp/sessionMcp.test.ts",
],
// Four-host installed-package matrix and the Windows curated lane.
Expand Down
40 changes: 40 additions & 0 deletions scripts/lib/native-ui-fixture-lifecycle.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** Selected fields of one fixture's token-verified ready record. */
export interface NativeUiFixtureScope {
readonly pid: number;
readonly window_id: number;
}

/** Owns one LaunchServices fixture from before its launch through verified exit. */
export interface NativeUiFixtureOwner {
readonly root: string;
/** Launch one fixture and return its token-verified ready scope. */
launch(application: string): Promise<NativeUiFixtureScope>;
/** Release every owned resource; failure retains them for another close. */
close(): Promise<void>;
}

/** Cleanup stays incomplete; the owner keeps its run token and root for retry. */
export class NativeUiFixtureCleanupFailure extends Error {
readonly _tag: "NativeUiFixtureCleanupFailure";
readonly cleanupOwner: NativeUiFixtureOwner;
readonly root: string;
readonly runId: string;
}

/** Allocate one private fixture root and the owner that alone may remove it. */
export function createNativeUiFixtureOwner(options?: {
readonly launcherCommand?: string;
readonly readyTimeoutMs?: number;
}): Promise<NativeUiFixtureOwner>;

/**
* Run one lane around a fixture owner and settle its process outcome in one
* place. Exits 130 or 143 when an interruption is the first failure.
*/
export function runNativeUiFixtureLane<Value>(
operation: (fixture: NativeUiFixtureOwner) => Promise<Value>,
options?: {
readonly launcherCommand?: string;
readonly readyTimeoutMs?: number;
},
): Promise<Value>;
300 changes: 300 additions & 0 deletions scripts/lib/native-ui-fixture-lifecycle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";

import { cleanupOwnedProcessGroup } from "../../dist/process/ProcessOwnership.js";
import {
observeProcessStartIdentity,
systemProcessOwnershipHost,
} from "../../dist/process/ProcessOwnershipObservation.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
} from "../../dist/process/ProviderProcess.js";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./verifier-cleanup.mjs";

const READY_TIMEOUT_MS = 20_000;
const READY_POLL_INTERVAL_MS = 100;
const LAUNCHER_OUTPUT_CLOSE_GRACE_MS = 5_000;
const INTERRUPT_EXIT_CODES = { SIGINT: 130, SIGTERM: 143 };

/** Cleanup stays incomplete; the owner keeps its run token and root for retry. */
export class NativeUiFixtureCleanupFailure extends Error {
_tag = "NativeUiFixtureCleanupFailure";

constructor(cleanupOwner, runId, options) {
const failures =
options.cause instanceof AggregateError
? options.cause.errors
: [options.cause];
super(
`Native UI fixture cleanup is incomplete; retained ${cleanupOwner.root} for run ${runId}: ${failures.map((failure) => failure.message).join("; ")}`,
options,
);
this.name = "NativeUiFixtureCleanupFailure";
this.cleanupOwner = cleanupOwner;
this.root = cleanupOwner.root;
this.runId = runId;
}
}

const positiveInteger = (value) => Number.isSafeInteger(value) && value > 0;

/** Parse the fixture's ready record once; only its selected fields leave here. */
const parseReadyRecord = (line) => {
let record;
try {
record = JSON.parse(line);
} catch (cause) {
throw new Error("Source-owned UI fixture wrote a malformed ready record", {
cause,
});
}
if (!positiveInteger(record?.pid) || !positiveInteger(record?.window_id))
throw new Error(
`Source-owned UI fixture ready record lacks a selected PID and window: ${line}`,
);
return { pid: record.pid, window_id: record.window_id };
};

/**
* Owns one LaunchServices fixture from before its launch through verified exit.
*
* `open` starts the app outside the launcher's process tree, so neither the
* launcher handle nor a later PID report can establish ownership. The run token
* placed in the launch environment can: the app carries it from its first
* instruction, and the shared sweep finds it with or without a ready record.
*/
class NativeUiFixtureOwner {
#runId = randomUUID();
#lifetime = new AbortController();
#launcherCommand;
#readyTimeoutMs;
#application;
#launching;
#launcher;
#ownership;
#released = false;
#closing;

constructor(root, launcherCommand, readyTimeoutMs) {
this.root = root;
this.#launcherCommand = launcherCommand;
this.#readyTimeoutMs = readyTimeoutMs;
}

/** Launch one fixture and return its token-verified ready scope. */
launch(application) {
if (this.#launching !== undefined)
return Promise.reject(
new Error("Native UI fixture owner launches one fixture"),
);
this.#launching = this.#launch(application);
return this.#launching;
}

/** Release every owned resource; failure retains them for another close. */
close() {
this.#lifetime.abort(new Error("Native UI fixture owner closed"));
if (this.#released) return Promise.resolve();
this.#closing ??= this.#release().finally(() => {
this.#closing = undefined;
});
return this.#closing;
}

async #launch(application) {
const { signal } = this.#lifetime;
signal.throwIfAborted();
this.#application = application;
const coordinates = join(this.root, "scope.json");
await writeFile(coordinates, "");
const owned = await spawnOwnedProviderProcess({
command: this.#launcherCommand,
// open(1) gives the app its own environment, which already holds the token.
// `--env` would add an argv entry the shared reader cannot prove, and a
// readable launcher exposing one fails every concurrent sweep on this host.
arguments: ["-W", "-n", "-g", "--stdout", coordinates, application],
runId: this.#runId,
sweepTokenOwnedProcesses: true,
signal,
});
// Retain both authorities before the first handshake await.
this.#ownership = owned.ownership;
this.#launcher = new ProviderProcessSupervisor(
{ process: owned.process, ownsProcessLifetime: true },
{ maxDiagnosticBytes: { stdout: 0, stderr: 64 * 1024 } },
);
const scope = parseReadyRecord(await this.#awaitReady(coordinates, signal));
const identity = await observeProcessStartIdentity(
scope.pid,
systemProcessOwnershipHost,
this.#runId,
);
if (identity?.state !== "readable")
throw new Error(
`Source-owned UI fixture ready record names PID ${String(scope.pid)}, which does not prove this run: ${identity?.reason ?? "process is absent"}`,
);
return scope;
}

async #awaitReady(coordinates, signal) {
const deadline = performance.now() + this.#readyTimeoutMs;
for (;;) {
signal.throwIfAborted();
// Select exit first: a record written just before exit is still accepted.
const exit = this.#launcher.observedExit();
const output = await readFile(coordinates, "utf8");
const end = output.indexOf("\n");
if (end >= 0) return output.slice(0, end);
if (exit !== undefined) {
// Exit can precede the launcher's final diagnostics on its pipe.
await this.#launcher.waitForOutputClose(LAUNCHER_OUTPUT_CLOSE_GRACE_MS);
throw new Error(
`Source-owned UI fixture launcher exited (code ${String(exit.code)}, signal ${String(exit.signal)}) before exposing one window: ${this.#launcher.snapshot().stderr.text.trim() || "no launcher diagnostics"}`,
);
}
const remaining = deadline - performance.now();
if (remaining <= 0)
throw new Error(
`Source-owned UI fixture failed to expose one window within ${String(this.#readyTimeoutMs / 1000)} seconds`,
);
// An abort rejects with a generic error; the loop reports its own reason.
await delay(Math.min(READY_POLL_INTERVAL_MS, remaining), undefined, {
signal,
}).catch(() => undefined);
}
}

async #release() {
// Join acquisition: a launcher created before the abort stays owned here.
await this.#launching?.catch(() => undefined);
try {
// Attempt each: a stuck launcher must not spare a token-proven fixture. A clean
// sweep proves nothing while a launch can still start, so every attempt sweeps
// again and only an attempt whose steps all succeeded removes the root.
await closeVerifierResources([
() => this.#stopLauncher(),
() => this.#retireApplication(),
() => this.#sweepFixture(),
]);
await rm(this.root, { recursive: true, force: true });
} catch (cause) {
throw new NativeUiFixtureCleanupFailure(this, this.#runId, { cause });
}
this.#released = true;
}

/**
* /usr/bin/open is a platform binary, so macOS withholds its token and the
* sweep cannot prove it. Its retained child handle is the authority instead,
* and the launcher must be gone before the sweep can succeed.
*/
async #stopLauncher() {
if (this.#launcher === undefined) return;
const stopped = await this.#launcher.stop();
if (stopped.status === "incomplete")
throw new Error(`Native UI fixture launcher: ${stopped.reason}`);
}

/**
* LaunchServices finishes a launch after `open` dies: on macOS 26 arm64 the
* app appeared after an immediate sweep when `open` was killed 10-20 ms after
* spawn. Retire the path it must exec, so the sweep that follows is not racing
* a launch that can still start.
*/
async #retireApplication() {
if (this.#launcher === undefined) return;
try {
await rename(this.#application, `${this.#application}.retired`);
} catch (cause) {
// Already retired by an earlier attempt, or never created.
if (cause.code !== "ENOENT") throw cause;
}
}

async #sweepFixture() {
if (this.#ownership === undefined) return;
const result = await cleanupOwnedProcessGroup(this.#ownership);
if (!result.cleaned)
throw new Error(`Native UI fixture processes: ${result.reason}`, {
cause: result,
});
}
}

/** Allocate one private fixture root and the owner that alone may remove it. */
export const createNativeUiFixtureOwner = async ({
launcherCommand = "/usr/bin/open",
readyTimeoutMs = READY_TIMEOUT_MS,
} = {}) =>
new NativeUiFixtureOwner(
await mkdtemp(join(tmpdir(), "rea-ui-fixture-")),
launcherCommand,
readyTimeoutMs,
);

/**
* Run one lane around a fixture owner and settle its process outcome in one place.
*
* A signal only records the interruption and ends the operation's race. The
* release always runs to settlement, and every signal stays handled until then:
* npm forwards Ctrl-C, so a default action on a repeated signal would kill the
* release and orphan the fixture. SIGKILL remains the operator's override.
*
* The first failure selects the status and every failure is reported. A signal
* after the operation failed keeps that failure and exit 1; a signal after it
* succeeded returns its completed value with the interrupted exit status.
*/
export const runNativeUiFixtureLane = async (operation, options) => {
const fixture = await createNativeUiFixtureOwner(options);
let interruption;
let operationFailure;
let interrupt;
const interrupted = new Promise((_, reject) => {
interrupt = reject;
});
interrupted.catch(() => undefined);
const listeners = Object.entries(INTERRUPT_EXIT_CODES).map(
([signal, exitCode]) => {
const listener = () => {
if (interruption !== undefined) return;
interruption = Object.assign(
new Error(`Native UI verification interrupted by ${signal}`),
{ exitCode },
);
interrupt(interruption);
};
process.on(signal, listener);
return [signal, listener];
},
);
try {
const value = await withVerifierCleanup(
() =>
Promise.race([operation(fixture), interrupted]).catch((cause) => {
operationFailure = cause;
throw cause;
}),
[() => fixture.close()],
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
);
if (interruption !== undefined) process.exitCode = interruption.exitCode;
return value;
} catch (cause) {
if (
interruption === undefined ||
(operationFailure !== undefined && operationFailure !== interruption)
)
throw cause;
console.error(cause === interruption ? cause.message : cause);
// An interrupted operation may still be running; nothing else ends it.
process.exit(interruption.exitCode);
} finally {
for (const [signal, listener] of listeners) process.off(signal, listener);
}
};
Loading
Loading