Files
rea/src/native/NativeUiHelperRuntime.ts
T
morluto 36d156ff05 fix(native): retain UI interruption source through final cleanup
Record caller cancellation versus the owned deadline once and share that provenance through helper execution, scenario waits and finalization. Preserve independent failures and partial reports, consolidate scenario failure handling and dispose listeners on every exit.

Fixes #5056
2026-10-19 01:41:52 +08:00

116 lines
3.8 KiB
TypeScript

import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { SWIFTC_INTERRUPT } from "../process/DarwinProcessRunTokenReader.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { safeParseJson } from "../domain/safeJson.js";
import type { NativeUiHelper } from "./NativeUiObservation.js";
import { NATIVE_UI_HELPER_MAX_BUFFER } from "./NativeUiOutputBudget.js";
import { NATIVE_UI_DEADLINE_MS } from "./NativeUiInterruptionControl.js";
export interface NativeUiHelperRuntime {
readonly invoke: NativeUiHelper;
readonly cleanupLease: AnalysisCleanupLease;
readonly signal: AbortSignal;
close(): Promise<void>;
resources(): readonly string[];
}
/** Lazily compile one owned helper per observation/scenario and remove its compiler cache. */
export const createNativeUiHelperRuntime = (
environment: Readonly<NodeJS.ProcessEnv>,
): NativeUiHelperRuntime => {
const selectedEnvironment = snapshotEnvironment(environment);
const deadlineSignal = AbortSignal.timeout(NATIVE_UI_DEADLINE_MS);
const operations = new ArtifactResourceScope();
let root: string | undefined;
let preparation: Promise<string> | undefined;
const cleanupLease = new AnalysisCleanupLease(async () => {
await operations.close();
if (root !== undefined) {
await rm(root, { recursive: true, force: true });
root = undefined;
preparation = undefined;
}
});
const prepare = async (signal: AbortSignal): Promise<string> => {
root = await mkdtemp(join(tmpdir(), "rea-native-ui-"));
const output = join(root, "observer");
const main = join(root, "main.swift");
await symlink(
fileURLToPath(
new URL("../../bridge/native/ReaNativeUI.swift", import.meta.url),
),
main,
);
// Keep swiftc's intermediate objects inside the removable root.
const compilerTemporary = join(root, "tmp");
await mkdir(compilerTemporary);
await execFileOutput(
"/usr/bin/xcrun",
[
"swiftc",
"-module-cache-path",
join(root, "modules"),
fileURLToPath(
new URL(
"../../bridge/native/NativeUIChildren.swift",
import.meta.url,
),
),
main,
"-o",
output,
],
{
timeout: 60_000,
maxBuffer: 1024 * 1024,
stopSignal: SWIFTC_INTERRUPT,
env: { ...selectedEnvironment, TMPDIR: compilerTemporary },
signal,
},
);
return output;
};
const invoke: NativeUiHelper = (parameters, callerSignal) =>
operations.run(async () => {
const signal =
callerSignal === undefined
? deadlineSignal
: AbortSignal.any([callerSignal, deadlineSignal]);
preparation ??= prepare(signal);
const executable = await preparation;
const output = await execFileOutput(
executable,
[JSON.stringify(parameters)],
{
timeout: 30_000,
maxBuffer: NATIVE_UI_HELPER_MAX_BUFFER,
stopSignal: "SIGTERM",
env: selectedEnvironment,
signal,
},
);
const parsed = safeParseJson(output.stdout);
if (!parsed.ok)
throw new Error(
`Native helper returned invalid JSON: ${parsed.error}`,
{
cause: parsed.cause,
},
);
return parsed.value;
});
return {
invoke,
cleanupLease,
signal: deadlineSignal,
close: () => cleanupLease.release(),
resources: () => (root === undefined ? [] : [root]),
};
};