Skip to content
Closed
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
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
42 changes: 42 additions & 0 deletions scripts/lib/native-ui-fixture-lifecycle.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/** 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;
/** Compile into the root; release stops this as swiftc requires and joins it. */
compile(arguments_: readonly string[]): Promise<unknown>;
/** 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>;
314 changes: 314 additions & 0 deletions scripts/lib/native-ui-fixture-lifecycle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
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 { SWIFTC_INTERRUPT } from "../../dist/process/DarwinProcessRunTokenReader.js";
import { execFileOutput } from "../../dist/process/ExecFileOutput.js";
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;
#compiling;
#launching;
#launcher;
#ownership;
#released = false;
#closing;

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

/** Compile into the root; release stops this as swiftc requires and joins it. */
compile(arguments_) {
const compiling = execFileOutput("/usr/bin/xcrun", arguments_, {
signal: this.#lifetime.signal,
stopSignal: SWIFTC_INTERRUPT,
lifetimePipe: true,
});
this.#compiling = Promise.allSettled([this.#compiling, compiling]);
return compiling;
}

/** 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 compile or launcher admitted before the abort stays owned.
await Promise.allSettled([this.#compiling, this.#launching]);
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