fix(android): clean Windows workspaces after owner termination (#2440)

* fix(android): clean Windows workspaces after owner termination

* fix(android): retain Windows workspace guardian on cleanup failure

* fix(android): preserve released guardian pipe state on cleanup retry

* fix(android): normalize extended local guardian parent paths

---------

Co-authored-by: LcokHcak <268763621+LcokHcak@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
LcokHcak 2026-10-13 23:03:16 +09:00 committed by GitHub
parent c45d912c91
commit c938f97fb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 528 additions and 26 deletions

View File

@ -611,6 +611,7 @@ jobs:
tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts
tests/boundary/filesystem/setupUnavailableProvider.test.ts tests/boundary/filesystem/setupUnavailableProvider.test.ts
tests/boundary/filesystem/windowsNpxRegistration.test.ts tests/boundary/filesystem/windowsNpxRegistration.test.ts
tests/boundary/android/workspaceGuardianRetry.test.ts
tests/acceptance/setup/homeDirectoryFallback.test.ts tests/acceptance/setup/homeDirectoryFallback.test.ts
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts tests/boundary/filesystem/webScriptManifestPaths.test.ts

View File

@ -226,9 +226,11 @@ REA never writes to the original APK or launches target code.
On Windows, stdin writes preserve stream backpressure and run outside the Node On Windows, stdin writes preserve stream backpressure and run outside the Node
event loop. Cancellation terminates the retained job, including descendants, event loop. Cancellation terminates the retained job, including descendants,
before workspace cleanup. An abrupt CLI/server process termination closes the before workspace cleanup. On Windows, a detached REA helper owns the private
job and stops Java, but cannot run JavaScript workspace cleanup; temporary workspace's native directory handle. An abrupt CLI/server owner termination
workspace files can remain after that forced exit. closes the Java job and the helper's input pipe; the helper then removes the
workspace by handle. The real Windows lane checks both effects. If the helper
also dies or the host loses power before it finishes, private files can remain.
If Java cannot start the bridge, REA reports the selected executable and retained If Java cannot start the bridge, REA reports the selected executable and retained
startup diagnostics. Select a full JDK through `JAVA_HOME` or `PATH`, and use startup diagnostics. Select a full JDK through `JAVA_HOME` or `PATH`, and use
@ -291,9 +293,11 @@ The original APK and host Java configuration remain unchanged.
The same lane runs on Windows x64 with the bundled native controls and a full The same lane runs on Windows x64 with the bundled native controls and a full
JDK. It checks CLI/MCP results, real MCP cancellation and disconnect, and Java JDK. It checks CLI/MCP results, real MCP cancellation and disconnect, and Java
exit after abrupt CLI owner termination. Windows forced termination is checked exit and workspace removal after abrupt CLI owner termination. Windows forced
separately from the POSIX CLI SIGTERM handler; it does not claim a structured termination is checked separately from the POSIX CLI SIGTERM handler. A
cancellation result or workspace removal after the owner is forcibly killed. synthetic two-owner test confirms that one owner's exit does not remove the
other owner's workspace. The lane does not claim a structured cancellation
result or power-loss recovery.
The download script verifies fixed SHA-256 values, reuses matching files and The download script verifies fixed SHA-256 values, reuses matching files and
refuses to overwrite a different existing file. APK/JAR files live under ignored refuses to overwrite a different existing file. APK/JAR files live under ignored

View File

@ -90,8 +90,10 @@ without this lane reports Windows native controls as unavailable.
root and ancestor leases for retry; a partially removed runtime accepts only root and ancestor leases for retry; a partially removed runtime accepts only
cleanup, and its registry entry remains until removal succeeds. Normal close, cancellation, cleanup, and its registry entry remains until removal succeeds. Normal close, cancellation,
startup failure, and timeout release provider resources. Abrupt owner death startup failure, and timeout release provider resources. Abrupt owner death
kills the owned job; it can leave private runtime files for later operator kills the owned job. The Android provider additionally uses a detached helper
cleanup and does not claim transactional deletion after a crash. that owns its private runtime directory and cleans it when the owner pipe
closes. Other providers can leave private runtime files after owner death;
helper death and host power loss are not transactional deletion.
Windows failures retain their constraint, requested coordinate, Win32 code, Windows failures retain their constraint, requested coordinate, Win32 code,
and system message. Unsupported filesystems and reparse paths are distinct and system message. Unsupported filesystems and reparse paths are distinct
@ -115,8 +117,10 @@ the supported device/UNC namespaces; existing admission checks still apply.
The bearer-token descriptor remains under its immutable private lease until The bearer-token descriptor remains under its immutable private lease until
`runtime_close` on Windows; POSIX removes the descriptor after the bridge reads `runtime_close` on Windows; POSIX removes the descriptor after the bridge reads
it. Job ownership guarantees process termination on owner death, not deletion it. Job ownership guarantees process termination on owner death. Android's
of private files after a crash. SUBST drive aliases and changing DOS-device detached workspace helper provides a separate cleanup owner for that provider,
but simultaneous helper death or power loss can still leave private files.
SUBST drive aliases and changing DOS-device
namespaces are outside the verified P0 scope and are not detected as a separate namespaces are outside the verified P0 scope and are not detected as a separate
alias policy. Mounted-folder paths that report a reparse tag are rejected by alias policy. Mounted-folder paths that report a reparse tag are rejected by
component admission; broader namespace variants remain unverified. Preserve component admission; broader namespace variants remain unverified. Preserve

View File

@ -62,6 +62,7 @@
"bridge/android/ReaJadxBridge.java", "bridge/android/ReaJadxBridge.java",
"bridge/mitmproxy/capture.py", "bridge/mitmproxy/capture.py",
"scripts/rea.mjs", "scripts/rea.mjs",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/electron-active-hook.cjs", "scripts/electron-active-hook.cjs",
"scripts/electron-active-hook-boundaries.cjs", "scripts/electron-active-hook-boundaries.cjs",
"scripts/hopper-demo-x11.py", "scripts/hopper-demo-x11.py",

View File

@ -55,6 +55,9 @@ export const SCOPES = {
"tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts", "tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts",
"tests/boundary/filesystem/setupUnavailableProvider.test.ts", "tests/boundary/filesystem/setupUnavailableProvider.test.ts",
"tests/boundary/filesystem/windowsNpxRegistration.test.ts", "tests/boundary/filesystem/windowsNpxRegistration.test.ts",
"tests/boundary/android/workspaceGuardianRetry.test.ts",
"src/android/JadxWorkspaceRoot.ts",
"scripts/rea-android-workspace-guardian.mjs",
"tests/acceptance/setup/homeDirectoryFallback.test.ts", "tests/acceptance/setup/homeDirectoryFallback.test.ts",
"tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts", "tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts",
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts", "src/domain/javascript/javascriptRuntimeStaticCandidates.ts",

View File

@ -0,0 +1,46 @@
#!/usr/bin/env node
import { setTimeout as delay } from "node:timers/promises";
import { WindowsPrivateRuntime } from "../dist/windows/WindowsPrivateRuntime.js";
if (process.platform !== "win32" || process.argv.length !== 3)
throw new Error(
"Android workspace guardian requires Windows and one parent directory",
);
// This process owns the native directory handle. Detached process creation
// keeps it alive when the REA owner is terminated without running JS cleanup.
const runtime = WindowsPrivateRuntime.create(process.argv[2], "rea-android-");
const ready = await new Promise((resolve) => {
process.stdout.once("error", () => resolve(false));
process.stdout.write(
JSON.stringify({
path: runtime.observation.path,
volumeSerial: runtime.observation.volumeSerial,
fileId: runtime.observation.fileId,
privateDacl: runtime.observation.privateDacl,
}) + "\n",
(error) => resolve(error == null),
);
});
// The owner holds the write end until close/death. A failed readiness pipe
// also grants cleanup authority, including owner death during startup.
if (ready) {
for await (const _ of process.stdin) {
// No commands are accepted; only EOF grants cleanup authority.
}
} else {
process.stdin.destroy();
}
// A failed native walk retains the directory identity lease for a later retry.
// Keep this process alive so a transient sharing violation cannot orphan it.
for (;;) {
try {
await runtime.close();
process.exit(0);
} catch {
await delay(1000);
}
}

View File

@ -52,8 +52,10 @@ try {
"bridge/ghidra/ReaGhidraBridge.java", "bridge/ghidra/ReaGhidraBridge.java",
"native/windows/build/manifest.json", "native/windows/build/manifest.json",
"native/windows/build/rea-windows-x64.node", "native/windows/build/rea-windows-x64.node",
"dist/android/JadxWorkspaceRoot.js",
"dist/main.js", "dist/main.js",
"dist/cli.js", "dist/cli.js",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/rea.mjs", "scripts/rea.mjs",
]) ])
if (!packagedPaths.has(required)) if (!packagedPaths.has(required))

View File

@ -308,3 +308,8 @@ if (process.env.REA_ANDROID_REPORT !== undefined)
JSON.stringify(report, null, 2) + "\n", JSON.stringify(report, null, 2) + "\n",
); );
console.log(JSON.stringify(report, null, 2)); console.log(JSON.stringify(report, null, 2));
assert.notEqual(
windowsLifecycle?.cli_abrupt_owner_exit_workspace_cleanup,
false,
"Android workspace remained after abrupt owner exit",
);

View File

@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { createJadxWorkspaceRoot } from "../../../dist/android/JadxWorkspaceRoot.js";
const source = fileURLToPath(import.meta.url);
const marker = "REA synthetic guardian isolation test\n";
const exists = (path) =>
stat(path).then(
() => true,
() => false,
);
if (process.argv[1] === source && process.argv[2] === "owner") {
const root = await createJadxWorkspaceRoot();
await writeFile(join(root.path, "synthetic.txt"), marker);
process.stdout.write(JSON.stringify({ path: root.path }) + "\n");
await new Promise(() => {});
}
const bounded = async (promise, milliseconds, label) => {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), milliseconds);
}),
]);
} finally {
clearTimeout(timer);
}
};
const launch = async () => {
const child = spawn(process.execPath, [source, "owner"], {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-8192);
});
const exit = new Promise((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
try {
const ready = await bounded(
lines[Symbol.asyncIterator]().next(),
20000,
`Android guardian owner readiness timed out: ${stderr}`,
);
assert.equal(ready.done, false, stderr);
const { path } = JSON.parse(ready.value);
assert.equal(typeof path, "string");
assert.equal(await exists(join(path, "synthetic.txt")), true);
return { child, exit, path };
} catch (cause) {
child.kill();
await bounded(exit, 15000, "Android guardian owner did not exit");
throw cause;
}
};
const stop = async ({ child, exit, path }) => {
assert.equal(child.kill(), true);
await bounded(exit, 15000, "Android guardian owner did not exit");
for (let attempt = 0; attempt < 100; attempt++) {
if (!(await exists(path))) return;
await delay(100);
}
throw new Error(`Android guardian retained ${path} after owner death`);
};
/** Two independent owners must never delete one another's private workspace. */
export async function verifyWindowsGuardianIsolation() {
assert.equal(process.platform, "win32");
const first = await launch();
let second;
try {
second = await launch();
assert.notEqual(first.path, second.path);
await stop(first);
assert.equal(
await exists(join(second.path, "synthetic.txt")),
true,
"First owner deleted the second owner's workspace",
);
await stop(second);
console.log("PASS Windows Android guardian two-owner cleanup isolation");
} finally {
for (const owner of [first, second]) {
if (owner === undefined) continue;
if (owner.child.exitCode === null && owner.child.signalCode === null)
owner.child.kill();
await bounded(owner.exit, 15000, "Android guardian owner did not exit");
}
}
}

View File

@ -17,6 +17,7 @@ import {
resolveJadxConfigurationInputs, resolveJadxConfigurationInputs,
validateJadxConfiguration, validateJadxConfiguration,
} from "../../../dist/android/JadxConfiguration.js"; } from "../../../dist/android/JadxConfiguration.js";
import { verifyWindowsGuardianIsolation } from "./windows-guardian-isolation.mjs";
/** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */ /** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */
export async function verifyWindowsAndroidLifecycle(options) { export async function verifyWindowsAndroidLifecycle(options) {
@ -24,15 +25,16 @@ export async function verifyWindowsAndroidLifecycle(options) {
await verifyJavaSelection(options.environment); await verifyJavaSelection(options.environment);
const cancellation = await verifyMcp(options, "cancel"); const cancellation = await verifyMcp(options, "cancel");
await verifyMcp(options, "disconnect"); await verifyMcp(options, "disconnect");
await verifyCliOwnerExit(options); const abruptWorkspaceRemoved = await verifyCliOwnerExit(options);
await verifyWindowsGuardianIsolation();
return { return {
mcp_cancelled_engine_exited: true, mcp_cancelled_engine_exited: true,
mcp_cancelled_workspace_removed: true, mcp_cancelled_workspace_removed: true,
mcp_ping_and_next_call: true, mcp_ping_and_next_call: true,
mcp_disconnect_engine_exited: true, mcp_disconnect_engine_exited: true,
cli_abrupt_owner_exit_engine_exited: true, cli_abrupt_owner_exit_engine_exited: true,
// A forced Windows process exit cannot execute JavaScript workspace cleanup. cli_abrupt_owner_exit_workspace_cleanup: abruptWorkspaceRemoved,
cli_abrupt_owner_exit_workspace_cleanup: false, guardian_two_owner_isolation: true,
observed_resources: cancellation, observed_resources: cancellation,
java_home_and_quoted_case_insensitive_path: true, java_home_and_quoted_case_insensitive_path: true,
}; };
@ -161,6 +163,7 @@ async function verifyCliOwnerExit({
path, path,
repository, repository,
}) { }) {
const baseline = new Set(await runtimeRoots());
const child = spawn( const child = spawn(
process.execPath, process.execPath,
[entrypoint, "inspect-android-package", path, "--format", "json"], [entrypoint, "inspect-android-package", path, "--format", "json"],
@ -185,7 +188,40 @@ async function verifyCliOwnerExit({
await deadline(exited, 15000); await deadline(exited, 15000);
await observer.verifyExit(); await observer.verifyExit();
assert.equal(output, "", "Aborted CLI published successful Evidence"); assert.equal(output, "", "Aborted CLI published successful Evidence");
console.log("PASS Windows CLI abrupt owner exit: real Java job cleanup"); const created = (await runtimeRoots()).filter(
(name) => !baseline.has(name),
);
assert.ok(
created.length <= 1,
"Concurrent Android workspaces make abrupt cleanup observation ambiguous",
);
if (created.length === 0) {
console.log(
"PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup",
);
return true;
}
const ownedName = created[0];
try {
await waitFor(
async () => !(await runtimeRoots()).includes(ownedName),
5000,
"Android workspace remained after abrupt owner exit",
);
} catch (cause) {
if (
!(cause instanceof Error) ||
cause.message !== "Android workspace remained after abrupt owner exit"
)
throw cause;
}
const removed = !(await runtimeRoots()).includes(ownedName);
console.log(
removed
? "PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup"
: "OBSERVED Windows CLI abrupt owner exit: Java job cleaned, workspace residue remains",
);
return removed;
} finally { } finally {
if (child.exitCode === null && child.signalCode === null) child.kill(); if (child.exitCode === null && child.signalCode === null) child.kill();
await deadline(exited, 15000); await deadline(exited, 15000);

View File

@ -18,7 +18,10 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js"; import { err, ok, type Result } from "../domain/result.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import {
createJadxWorkspaceRoot,
type JadxWorkspaceRoot,
} from "./JadxWorkspaceRoot.js";
import { import {
snapshotAndroidEngine, snapshotAndroidEngine,
snapshotAndroidTarget, snapshotAndroidTarget,
@ -152,7 +155,7 @@ const hashAndroidSource = async (
interface PendingCleanup { interface PendingCleanup {
session: JadxSession | undefined; session: JadxSession | undefined;
root: PrivateRuntimeRoot | undefined; root: JadxWorkspaceRoot | undefined;
readonly previousError: AnalysisError | undefined; readonly previousError: AnalysisError | undefined;
} }
@ -211,7 +214,7 @@ const cleanup = async (
interface RetainedSession { interface RetainedSession {
readonly key: string; readonly key: string;
readonly root: PrivateRuntimeRoot; readonly root: JadxWorkspaceRoot;
readonly session: JadxSession; readonly session: JadxSession;
readonly snapshot: string; readonly snapshot: string;
readonly jarHash: string; readonly jarHash: string;
@ -325,7 +328,7 @@ export class JadxProvider implements AndroidAnalysisPort {
options?.signal === undefined options?.signal === undefined
? timeout ? timeout
: AbortSignal.any([options.signal, timeout]); : AbortSignal.any([options.signal, timeout]);
let root: PrivateRuntimeRoot | undefined; let root: JadxWorkspaceRoot | undefined;
let session: JadxSession | undefined; let session: JadxSession | undefined;
try { try {
if (target.format !== "apk") if (target.format !== "apk")
@ -389,20 +392,24 @@ export class JadxProvider implements AndroidAnalysisPort {
request.operation, request.operation,
signal, signal,
); );
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" }); root = await createJadxWorkspaceRoot();
const workspaceSignal =
root.failureSignal === undefined
? signal
: AbortSignal.any([signal, root.failureSignal]);
const engine = await snapshotAndroidEngine( const engine = await snapshotAndroidEngine(
configuration.jar, configuration.jar,
jarHash, jarHash,
root.path, root.path,
request.operation, request.operation,
signal, workspaceSignal,
); );
const snapshot = await snapshotAndroidTarget( const snapshot = await snapshotAndroidTarget(
target.path, target.path,
target.sha256, target.sha256,
root.path, root.path,
request.operation, request.operation,
signal, workspaceSignal,
); );
const bridge = join(root.path, "ReaJadxBridge.java"); const bridge = join(root.path, "ReaJadxBridge.java");
await copyFile(BRIDGE_SOURCE, bridge); await copyFile(BRIDGE_SOURCE, bridge);
@ -410,7 +417,7 @@ export class JadxProvider implements AndroidAnalysisPort {
const copiedBridgeHash = await hashAndroidSource( const copiedBridgeHash = await hashAndroidSource(
bridge, bridge,
request.operation, request.operation,
signal, workspaceSignal,
); );
if (copiedBridgeHash !== bridgeHash) if (copiedBridgeHash !== bridgeHash)
throw androidSourceChanged( throw androidSourceChanged(
@ -418,7 +425,7 @@ export class JadxProvider implements AndroidAnalysisPort {
BRIDGE_SOURCE, BRIDGE_SOURCE,
undefined, undefined,
); );
signal.throwIfAborted(); workspaceSignal.throwIfAborted();
session = new JadxSession( session = new JadxSession(
{ {
command: configuration.java, command: configuration.java,
@ -430,7 +437,7 @@ export class JadxProvider implements AndroidAnalysisPort {
], ],
cwd: root.path, cwd: root.path,
hostEnvironment: this.environment, hostEnvironment: this.environment,
signal, signal: workspaceSignal,
}, },
this.launcher, this.launcher,
); );
@ -445,6 +452,10 @@ export class JadxProvider implements AndroidAnalysisPort {
} }
root = retained.root; root = retained.root;
session = retained.session; session = retained.session;
const activeSignal =
root.failureSignal === undefined
? signal
: AbortSignal.any([signal, root.failureSignal]);
const { snapshot } = retained; const { snapshot } = retained;
const outcome = ok( const outcome = ok(
await session.execute({ await session.execute({
@ -453,10 +464,10 @@ export class JadxProvider implements AndroidAnalysisPort {
snapshot, snapshot,
jarHash: retained.jarHash, jarHash: retained.jarHash,
bridgeHash: retained.bridgeHash, bridgeHash: retained.bridgeHash,
signal, signal: activeSignal,
}), }),
); );
signal.throwIfAborted(); activeSignal.throwIfAborted();
this.#retained = retained; this.#retained = retained;
return outcome; return outcome;
} catch (cause) { } catch (cause) {

View File

@ -0,0 +1,224 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { lstat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
/** Match the native owner's ordinary spelling only for extended local drive paths. */
const localDrivePath = (path: string): string =>
path.replace(/^\\\\\?\\(?=[A-Za-z]:\\)/u, "");
const guardianSource = fileURLToPath(
new URL("../../scripts/rea-android-workspace-guardian.mjs", import.meta.url),
);
const readySchema = z.strictObject({
path: z.string(),
volumeSerial: z.string().regex(/^[a-f0-9]{16}$/u),
fileId: z.string().regex(/^[a-f0-9]{32}$/u),
privateDacl: z.literal(true),
});
/** The Android provider needs a path and a cleanup owner, regardless of host. */
export interface JadxWorkspaceRoot {
readonly path: string;
readonly failureSignal?: AbortSignal;
close(): Promise<void>;
}
type GuardianExit = {
readonly code: number | null;
readonly signal: NodeJS.Signals | null;
readonly error?: Error;
};
const boundedWait = async <T>(
promise: Promise<T>,
milliseconds: number,
label: string,
): Promise<T> => {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(label)), milliseconds);
}),
]);
} finally {
clearTimeout(timer);
}
};
/** A detached guardian retains native cleanup ownership without keeping its caller alive. */
const releaseGuardianReferences = (
child: ChildProcessWithoutNullStreams,
): void => {
child.stdin.destroy();
child.stdout.destroy();
child.stderr.destroy();
child.unref();
};
class WindowsJadxWorkspaceRoot implements JadxWorkspaceRoot {
readonly #failure = new AbortController();
readonly failureSignal = this.#failure.signal;
readonly #child: ChildProcessWithoutNullStreams;
readonly #exit: Promise<GuardianExit>;
readonly #diagnostics: () => string;
#closing = false;
#closePromise: Promise<void> | undefined;
private constructor(
readonly path: string,
child: ChildProcessWithoutNullStreams,
exit: Promise<GuardianExit>,
diagnostics: () => string,
) {
this.#child = child;
this.#exit = exit;
this.#diagnostics = diagnostics;
void this.#exit.then((result) => {
if (!this.#closing)
this.#failure.abort(
new Error(
`Android workspace guardian exited before cleanup: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}`,
),
);
});
}
static async create(): Promise<WindowsJadxWorkspaceRoot> {
const parent = tmpdir();
const child = spawn(process.execPath, [guardianSource, parent], {
detached: true,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
env: {
SystemRoot: process.env.SystemRoot,
WINDIR: process.env.WINDIR,
TEMP: parent,
TMP: parent,
},
});
let diagnostics = "";
child.stderr.on("data", (chunk: Buffer) => {
diagnostics = (diagnostics + chunk.toString()).slice(-8192);
});
const exit = new Promise<GuardianExit>((resolve) => {
child.once("error", (error: Error) =>
resolve({ code: null, signal: null, error }),
);
child.once("close", (code, signal) => resolve({ code, signal }));
});
const ready = new Promise<string>((resolve, reject) => {
let frame = "";
child.stdout.on("data", (chunk: Buffer) => {
frame += chunk.toString();
if (frame.length > 4096) {
reject(
new Error("Android workspace guardian readiness exceeded 4 KiB"),
);
return;
}
const end = frame.indexOf("\n");
if (end !== -1) resolve(frame.slice(0, end));
});
child.stdout.once("error", reject);
});
try {
const line = await boundedWait(
Promise.race([
ready,
exit.then((result) => {
throw new Error(
`Android workspace guardian exited during startup: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}; ${diagnostics}`,
);
}),
]),
15000,
"Android workspace guardian startup timed out",
);
const observation = readySchema.parse(JSON.parse(line));
if (
win32.dirname(localDrivePath(observation.path)).toLowerCase() !==
localDrivePath(win32.resolve(parent)).toLowerCase() ||
!/^rea-android-[a-f0-9]{32}$/u.test(win32.basename(observation.path))
)
throw new Error(
"Android workspace guardian returned an unexpected root",
);
child.stdout.resume();
return new WindowsJadxWorkspaceRoot(
observation.path,
child,
exit,
() => diagnostics,
);
} catch (cause) {
if (!child.stdin.destroyed) child.stdin.end();
try {
await boundedWait(
exit,
30000,
"Android workspace guardian did not settle after startup failure",
);
} catch (cleanupFailure) {
throw new AggregateError(
[cause, cleanupFailure],
"Android workspace guardian startup and cleanup failed",
);
} finally {
releaseGuardianReferences(child);
}
throw cause;
}
}
close(): Promise<void> {
this.#closing = true;
this.#closePromise ??= this.#finish().catch((cause: unknown) => {
this.#closePromise = undefined;
throw cause;
});
return this.#closePromise;
}
async #finish(): Promise<void> {
if (!this.#child.stdin.destroyed) this.#child.stdin.end();
let result: GuardianExit;
try {
result = await boundedWait(
this.#exit,
120000,
`Android workspace guardian cleanup timed out for ${this.path}`,
);
} catch (cause) {
releaseGuardianReferences(this.#child);
throw cause;
}
if (result.error !== undefined || result.code !== 0)
throw new Error(
`Android workspace guardian cleanup failed for ${this.path}: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}; ${this.#diagnostics()}`,
);
try {
await lstat(this.path);
} catch (cause) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return;
throw cause;
}
throw new Error(
`Android workspace guardian exited but retained ${this.path}`,
);
}
}
/** POSIX retains its in-process root; Windows keeps a detached native owner. */
export const createJadxWorkspaceRoot = (): Promise<JadxWorkspaceRoot> =>
process.platform === "win32"
? WindowsJadxWorkspaceRoot.create()
: PrivateRuntimeRoot.create({ prefix: "rea-android-" });

View File

@ -0,0 +1,60 @@
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import { expect, it } from "vitest";
it.skipIf(process.platform !== "win32")(
"retains guardian cleanup ownership through more than 100 transient failures",
async () => {
const guardian = await readFile(
new URL(
"../../../scripts/rea-android-workspace-guardian.mjs",
import.meta.url,
),
"utf8",
);
const withoutTimer = guardian
.replace(/^#!.*\r?\n/u, "")
.replace(
'import { setTimeout as delay } from "node:timers/promises";',
"const delay = async () => {};",
);
const injected = withoutTimer.replace(
'import { WindowsPrivateRuntime } from "../dist/windows/WindowsPrivateRuntime.js";',
`let attempts = 0;
const WindowsPrivateRuntime = {
create: () => ({
observation: { path: "synthetic-root", volumeSerial: "0", fileId: "0", privateDacl: true },
close: async () => { if (++attempts <= 101) throw new Error("transient cleanup failure"); },
}),
};`,
);
expect(withoutTimer).not.toBe(guardian);
expect(injected).not.toBe(withoutTimer);
const child = spawn(
process.execPath,
[
"--input-type=module",
"-e",
'process.argv.splice(1, 0, "guardian.mjs");\n' + injected,
"synthetic-parent",
],
{ signal: AbortSignal.timeout(3000) },
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.stdin.end();
const code = await new Promise<number | null>((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
expect(code, stderr).toBe(0);
expect(JSON.parse(stdout.trim())).toMatchObject({ path: "synthetic-root" });
},
);