Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 8247f72666 fix(native): stop and join verifier compiles before removing the root
A signal during either Swift compile ended the operation's race while the compiler
kept running, so release removed the root beneath it and the verifier exited without
stopping it. On base, the same signal left the root behind instead.

Run both compiles through the fixture owner with the existing `execFileOutput` and
`SWIFTC_INTERRUPT` path. Closing the owner now stops them as swiftc requires, and
release joins them before it touches the root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:31:41 +00:00
Kaoru0822andClaude Opus 5.5 50eb50d26a Merge origin/main into fix/8559-native-ui-fixture-ownership
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:02:07 -08:00
Kaoru0822andClaude Opus 5.5 61e8653bf6 fix(native): sweep again on every native UI fixture release attempt
A clean sweep was cached even when retiring the application path had failed in
the same attempt. If the rename was refused while a launch was pending, the sweep
found nothing, the app then started, and the retry skipped the sweep and removed
the root beneath the running fixture.

Remove the cached flag. A 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.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:40:49 +00:00
Kaoru0822 c847a45b54 Merge remote-tracking branch 'origin/main' into fix/8559-native-ui-fixture-ownership 2026-11-02 08:30:03 +00:00
Kaoru0822 9a8733e528 Merge remote-tracking branch 'origin/main' into fix/8559-native-ui-fixture-ownership 2026-11-02 08:12:44 +00:00
Kaoru0822andClaude Opus 5.5 48bb58524d test(native): order the in-flight launch check with a barrier
The in-flight launch test read `rootPresent` from a stand-in polling every 2 ms,
so a delayed stand-in could see the root already gone and fail correct cleanup.
Hold the real release just before root removal until the test has seen the launch
refused, and start release only once the launch is admitted, which the 300 ms
handshake budget did not ensure in a separate process.

Remove the same kind of dependency elsewhere in the file: an elapsed-time bound
the exit message already proves, and safety expiries shorter than the test
timeouts. The launcher diagnostics grace also becomes 5 s, so a stalled event loop
cannot drop the launcher's stderr from a startup failure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:12:40 +00:00
Kaoru0822 f9160aa668 Merge remote-tracking branch 'origin/main' into fix/8559-native-ui-fixture-ownership 2026-11-02 07:09:05 +00:00
Kaoru0822andClaude Opus 5.5 f2e05257f5 fix(native): finish the fixture release through signals during cleanup
The verifier's signal handlers raced its own cleanup. `process.once` left a
repeated signal to its default action, which killed the process mid-release
and orphaned the fixture; npm forwarding Ctrl-C makes that repeat ordinary.
The handler's own `process.exit` also beat the main path, so a signal during
cleanup replaced an existing failure with an interrupted status.

Settle the process outcome in one place. A signal now only records the
interruption and ends the operation's race, the release always runs to
settlement, and every signal stays handled until then. The first failure
selects the status and every failure is reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:09:01 +00:00
Kaoru0822andClaude Opus 5.5 4d9021913c fix(native): keep UI fixture ownership through a missing handshake
LaunchServices starts the fixture outside the launcher's process tree, and
the verifier learned its PID only from the ready record. A missing handshake
therefore stopped `open`, removed the fixture root and left the app running,
reparented to PID 1.

Own the launch from before it starts: the run token reaches the app through
the launch environment, so the shared sweep finds it with or without a ready
record. Stop the launcher through its retained handle, retire the application
path so an in-flight launch cannot start, sweep, and only then remove the root.
A refused cleanup retains the root and run for retry. The ready PID must prove
the token before it is used, and an interrupted verifier releases its fixture.

Fixes #8559

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 06:45:00 +00:00
8 changed files with 1548 additions and 67 deletions

No files matched your search

+1
View File
@@ -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:
+5 -1
View File
@@ -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.
+3
View File
@@ -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.
@@ -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
View File
@@ -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()],
);
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);
}
};
+10 -66
View File
@@ -1,7 +1,5 @@
import { execFile, spawn } from "node:child_process";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@@ -9,21 +7,16 @@ import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { observeNativeUi } from "../dist/native/NativeUiObservation.js";
import { NATIVE_UI_HELPER_MAX_BUFFER } from "../dist/native/NativeUiOutputBudget.js";
import { artifactMcpResult, withArtifactMcp } from "./lib/artifact-e2e.mjs";
import { observeProcessStartIdentity } from "../dist/process/ProcessOwnershipObservation.js";
import { runNativeUiFixtureLane } from "./lib/native-ui-fixture-lifecycle.mjs";
if (process.platform !== "darwin")
throw new Error(
"Native UI verification requires macOS, an interactive desktop and Xcode command-line tools",
);
const root = await mkdtemp(join(tmpdir(), "rea-ui-fixture-"));
let child;
let childExit;
let fixturePid;
let fixtureIdentity;
let report;
try {
const report = await runNativeUiFixtureLane(async (fixture) => {
const { root } = fixture;
const childRetrievalTest = join(root, "child-retrieval-test");
await promisify(execFile)("/usr/bin/xcrun", [
await fixture.compile([
"swiftc",
fileURLToPath(
new URL("../bridge/native/NativeUIChildren.swift", import.meta.url),
@@ -80,7 +73,7 @@ try {
`<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleExecutable</key><string>fixture</string><key>CFBundleIdentifier</key><string>io.rea.source-owned-ui-fixture</string><key>CFBundleName</key><string>REA UI Fixture</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist>`,
);
const executable = join(contents, "MacOS", "fixture");
await promisify(execFile)("/usr/bin/xcrun", [
await fixture.compile([
"swiftc",
"-module-cache-path",
join(root, "modules"),
@@ -90,35 +83,7 @@ try {
"-o",
executable,
]);
const coordinates = join(root, "scope.json");
await writeFile(coordinates, "");
child = spawn(
"/usr/bin/open",
["-W", "-n", "-g", "--stdout", coordinates, join(root, "Fixture.app")],
{ stdio: "ignore" },
);
childExit = new Promise((resolve) => child.once("exit", resolve));
let scope;
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
const output = await readFile(coordinates, "utf8");
if (output.includes("\n")) {
scope = JSON.parse(output.split("\n")[0]);
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (scope === undefined)
throw new Error(
"Source-owned UI fixture failed to expose one window within 20 seconds",
);
fixturePid = scope.pid;
fixtureIdentity = await observeProcessStartIdentity(fixturePid);
assert.equal(
fixtureIdentity?.state,
"readable",
"Fixture OS start identity is unavailable",
);
const scope = await fixture.launch(join(root, "Fixture.app"));
const target = await parseBinaryTarget(executable);
if (!target.ok) throw target.error;
const observation = await observeNativeUi(
@@ -247,7 +212,7 @@ try {
);
if (mismatch.ok || !mismatch.error.message.includes("target-mismatch"))
throw new Error("Real helper did not reject changed target bytes");
report = {
return {
ok: true,
verification_status: observation.ok ? "passed" : "permission-boundary-only",
positive_e2e: observation.ok,
@@ -259,26 +224,5 @@ try {
mismatch_rejected: true,
target_owned: true,
};
} finally {
if (fixturePid !== undefined && fixtureIdentity?.state === "readable") {
const current = await observeProcessStartIdentity(fixturePid);
if (current !== undefined) {
assert.ok(
current.state === "readable" &&
current.identity === fixtureIdentity.identity,
"Fixture PID identity changed; refusing to signal another process",
);
try {
process.kill(fixturePid, "SIGTERM");
} catch (cause) {
if (cause.code !== "ESRCH") throw cause;
}
}
}
if (child !== undefined && child.exitCode === null) {
child.kill("SIGTERM");
await childExit;
}
await rm(root, { recursive: true, force: true });
}
});
process.stdout.write(`${JSON.stringify(report)}\n`);
@@ -892,6 +892,18 @@ it.each([
"tests/boundary/process/darwinProcessRunTokenReader.test.ts",
{ code: true, apple: true, package: false, runtime: false },
],
[
"tests/boundary/process/nativeUiVerifierFixtureOwnership.test.ts",
{ code: true, apple: true, package: false, runtime: false },
],
[
"scripts/verify-native-ui.mjs",
{ code: true, apple: true, package: false, runtime: false },
],
[
"scripts/lib/native-ui-fixture-lifecycle.mjs",
{ code: true, apple: true, package: false, runtime: false },
],
[
"tests/boundary/cli/nativePlistCli.test.ts",
{ code: true, apple: true, package: false, runtime: false },
File diff suppressed because it is too large. Load diff