From c938f97fb2a84229a9de17fc20276ce3d009ee3b Mon Sep 17 00:00:00 2001 From: LcokHcak Date: Tue, 13 Oct 2026 23:03:16 +0900 Subject: [PATCH] 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> --- .github/workflows/ci.yml | 1 + docs/android-analysis.md | 16 +- native/windows/README.md | 12 +- package.json | 1 + scripts/ci/scopes.mjs | 3 + scripts/rea-android-workspace-guardian.mjs | 46 ++++ scripts/verify-windows-package.mjs | 2 + scripts/verify/android/analysis.mjs | 5 + .../android/windows-guardian-isolation.mjs | 105 ++++++++ scripts/verify/android/windows-lifecycle.mjs | 44 +++- src/android/JadxProvider.ts | 35 ++- src/android/JadxWorkspaceRoot.ts | 224 ++++++++++++++++++ .../android/workspaceGuardianRetry.test.ts | 60 +++++ 13 files changed, 528 insertions(+), 26 deletions(-) create mode 100644 scripts/rea-android-workspace-guardian.mjs create mode 100644 scripts/verify/android/windows-guardian-isolation.mjs create mode 100644 src/android/JadxWorkspaceRoot.ts create mode 100644 tests/boundary/android/workspaceGuardianRetry.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dfd067f3..11d5dc30b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -611,6 +611,7 @@ jobs: tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts tests/boundary/filesystem/setupUnavailableProvider.test.ts tests/boundary/filesystem/windowsNpxRegistration.test.ts + tests/boundary/android/workspaceGuardianRetry.test.ts tests/acceptance/setup/homeDirectoryFallback.test.ts tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts tests/boundary/filesystem/webScriptManifestPaths.test.ts diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 81c9f9285..50087f464 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -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 event loop. Cancellation terminates the retained job, including descendants, -before workspace cleanup. An abrupt CLI/server process termination closes the -job and stops Java, but cannot run JavaScript workspace cleanup; temporary -workspace files can remain after that forced exit. +before workspace cleanup. On Windows, a detached REA helper owns the private +workspace's native directory handle. An abrupt CLI/server owner termination +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 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 JDK. It checks CLI/MCP results, real MCP cancellation and disconnect, and Java -exit after abrupt CLI owner termination. Windows forced termination is checked -separately from the POSIX CLI SIGTERM handler; it does not claim a structured -cancellation result or workspace removal after the owner is forcibly killed. +exit and workspace removal after abrupt CLI owner termination. Windows forced +termination is checked separately from the POSIX CLI SIGTERM handler. A +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 refuses to overwrite a different existing file. APK/JAR files live under ignored diff --git a/native/windows/README.md b/native/windows/README.md index 7432f15fd..86103ce9a 100644 --- a/native/windows/README.md +++ b/native/windows/README.md @@ -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 cleanup, and its registry entry remains until removal succeeds. Normal close, cancellation, startup failure, and timeout release provider resources. Abrupt owner death - kills the owned job; it can leave private runtime files for later operator - cleanup and does not claim transactional deletion after a crash. + kills the owned job. The Android provider additionally uses a detached helper + 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, 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 `runtime_close` on Windows; POSIX removes the descriptor after the bridge reads -it. Job ownership guarantees process termination on owner death, not deletion -of private files after a crash. SUBST drive aliases and changing DOS-device +it. Job ownership guarantees process termination on owner death. Android's +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 alias policy. Mounted-folder paths that report a reparse tag are rejected by component admission; broader namespace variants remain unverified. Preserve diff --git a/package.json b/package.json index bc3492d2f..70ab60d1e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "bridge/android/ReaJadxBridge.java", "bridge/mitmproxy/capture.py", "scripts/rea.mjs", + "scripts/rea-android-workspace-guardian.mjs", "scripts/electron-active-hook.cjs", "scripts/electron-active-hook-boundaries.cjs", "scripts/hopper-demo-x11.py", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index e8af46d3b..eae2430e5 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -55,6 +55,9 @@ export const SCOPES = { "tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts", "tests/boundary/filesystem/setupUnavailableProvider.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/boundary/filesystem/javascriptApplicationWorkflows.test.ts", "src/domain/javascript/javascriptRuntimeStaticCandidates.ts", diff --git a/scripts/rea-android-workspace-guardian.mjs b/scripts/rea-android-workspace-guardian.mjs new file mode 100644 index 000000000..93ba42d2d --- /dev/null +++ b/scripts/rea-android-workspace-guardian.mjs @@ -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); + } +} diff --git a/scripts/verify-windows-package.mjs b/scripts/verify-windows-package.mjs index 67dee06c2..7ac204868 100644 --- a/scripts/verify-windows-package.mjs +++ b/scripts/verify-windows-package.mjs @@ -52,8 +52,10 @@ try { "bridge/ghidra/ReaGhidraBridge.java", "native/windows/build/manifest.json", "native/windows/build/rea-windows-x64.node", + "dist/android/JadxWorkspaceRoot.js", "dist/main.js", "dist/cli.js", + "scripts/rea-android-workspace-guardian.mjs", "scripts/rea.mjs", ]) if (!packagedPaths.has(required)) diff --git a/scripts/verify/android/analysis.mjs b/scripts/verify/android/analysis.mjs index c29a25d3a..30024b557 100644 --- a/scripts/verify/android/analysis.mjs +++ b/scripts/verify/android/analysis.mjs @@ -308,3 +308,8 @@ if (process.env.REA_ANDROID_REPORT !== undefined) JSON.stringify(report, null, 2) + "\n", ); console.log(JSON.stringify(report, null, 2)); +assert.notEqual( + windowsLifecycle?.cli_abrupt_owner_exit_workspace_cleanup, + false, + "Android workspace remained after abrupt owner exit", +); diff --git a/scripts/verify/android/windows-guardian-isolation.mjs b/scripts/verify/android/windows-guardian-isolation.mjs new file mode 100644 index 000000000..1a6308970 --- /dev/null +++ b/scripts/verify/android/windows-guardian-isolation.mjs @@ -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"); + } + } +} diff --git a/scripts/verify/android/windows-lifecycle.mjs b/scripts/verify/android/windows-lifecycle.mjs index 5c1a9c02d..d2b281c60 100644 --- a/scripts/verify/android/windows-lifecycle.mjs +++ b/scripts/verify/android/windows-lifecycle.mjs @@ -17,6 +17,7 @@ import { resolveJadxConfigurationInputs, validateJadxConfiguration, } from "../../../dist/android/JadxConfiguration.js"; +import { verifyWindowsGuardianIsolation } from "./windows-guardian-isolation.mjs"; /** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */ export async function verifyWindowsAndroidLifecycle(options) { @@ -24,15 +25,16 @@ export async function verifyWindowsAndroidLifecycle(options) { await verifyJavaSelection(options.environment); const cancellation = await verifyMcp(options, "cancel"); await verifyMcp(options, "disconnect"); - await verifyCliOwnerExit(options); + const abruptWorkspaceRemoved = await verifyCliOwnerExit(options); + await verifyWindowsGuardianIsolation(); return { mcp_cancelled_engine_exited: true, mcp_cancelled_workspace_removed: true, mcp_ping_and_next_call: true, mcp_disconnect_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: false, + cli_abrupt_owner_exit_workspace_cleanup: abruptWorkspaceRemoved, + guardian_two_owner_isolation: true, observed_resources: cancellation, java_home_and_quoted_case_insensitive_path: true, }; @@ -161,6 +163,7 @@ async function verifyCliOwnerExit({ path, repository, }) { + const baseline = new Set(await runtimeRoots()); const child = spawn( process.execPath, [entrypoint, "inspect-android-package", path, "--format", "json"], @@ -185,7 +188,40 @@ async function verifyCliOwnerExit({ await deadline(exited, 15000); await observer.verifyExit(); 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 { if (child.exitCode === null && child.signalCode === null) child.kill(); await deadline(exited, 15000); diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 6fda5a143..3980ce318 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -18,7 +18,10 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; -import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { + createJadxWorkspaceRoot, + type JadxWorkspaceRoot, +} from "./JadxWorkspaceRoot.js"; import { snapshotAndroidEngine, snapshotAndroidTarget, @@ -152,7 +155,7 @@ const hashAndroidSource = async ( interface PendingCleanup { session: JadxSession | undefined; - root: PrivateRuntimeRoot | undefined; + root: JadxWorkspaceRoot | undefined; readonly previousError: AnalysisError | undefined; } @@ -211,7 +214,7 @@ const cleanup = async ( interface RetainedSession { readonly key: string; - readonly root: PrivateRuntimeRoot; + readonly root: JadxWorkspaceRoot; readonly session: JadxSession; readonly snapshot: string; readonly jarHash: string; @@ -325,7 +328,7 @@ export class JadxProvider implements AndroidAnalysisPort { options?.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout]); - let root: PrivateRuntimeRoot | undefined; + let root: JadxWorkspaceRoot | undefined; let session: JadxSession | undefined; try { if (target.format !== "apk") @@ -389,20 +392,24 @@ export class JadxProvider implements AndroidAnalysisPort { request.operation, 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( configuration.jar, jarHash, root.path, request.operation, - signal, + workspaceSignal, ); const snapshot = await snapshotAndroidTarget( target.path, target.sha256, root.path, request.operation, - signal, + workspaceSignal, ); const bridge = join(root.path, "ReaJadxBridge.java"); await copyFile(BRIDGE_SOURCE, bridge); @@ -410,7 +417,7 @@ export class JadxProvider implements AndroidAnalysisPort { const copiedBridgeHash = await hashAndroidSource( bridge, request.operation, - signal, + workspaceSignal, ); if (copiedBridgeHash !== bridgeHash) throw androidSourceChanged( @@ -418,7 +425,7 @@ export class JadxProvider implements AndroidAnalysisPort { BRIDGE_SOURCE, undefined, ); - signal.throwIfAborted(); + workspaceSignal.throwIfAborted(); session = new JadxSession( { command: configuration.java, @@ -430,7 +437,7 @@ export class JadxProvider implements AndroidAnalysisPort { ], cwd: root.path, hostEnvironment: this.environment, - signal, + signal: workspaceSignal, }, this.launcher, ); @@ -445,6 +452,10 @@ export class JadxProvider implements AndroidAnalysisPort { } root = retained.root; session = retained.session; + const activeSignal = + root.failureSignal === undefined + ? signal + : AbortSignal.any([signal, root.failureSignal]); const { snapshot } = retained; const outcome = ok( await session.execute({ @@ -453,10 +464,10 @@ export class JadxProvider implements AndroidAnalysisPort { snapshot, jarHash: retained.jarHash, bridgeHash: retained.bridgeHash, - signal, + signal: activeSignal, }), ); - signal.throwIfAborted(); + activeSignal.throwIfAborted(); this.#retained = retained; return outcome; } catch (cause) { diff --git a/src/android/JadxWorkspaceRoot.ts b/src/android/JadxWorkspaceRoot.ts new file mode 100644 index 000000000..15b862320 --- /dev/null +++ b/src/android/JadxWorkspaceRoot.ts @@ -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; +} + +type GuardianExit = { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + readonly error?: Error; +}; + +const boundedWait = async ( + promise: Promise, + milliseconds: number, + label: string, +): Promise => { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_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; + readonly #diagnostics: () => string; + #closing = false; + #closePromise: Promise | undefined; + + private constructor( + readonly path: string, + child: ChildProcessWithoutNullStreams, + exit: Promise, + 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 { + 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((resolve) => { + child.once("error", (error: Error) => + resolve({ code: null, signal: null, error }), + ); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + const ready = new Promise((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 { + this.#closing = true; + this.#closePromise ??= this.#finish().catch((cause: unknown) => { + this.#closePromise = undefined; + throw cause; + }); + return this.#closePromise; + } + + async #finish(): Promise { + 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 => + process.platform === "win32" + ? WindowsJadxWorkspaceRoot.create() + : PrivateRuntimeRoot.create({ prefix: "rea-android-" }); diff --git a/tests/boundary/android/workspaceGuardianRetry.test.ts b/tests/boundary/android/workspaceGuardianRetry.test.ts new file mode 100644 index 000000000..fda07135c --- /dev/null +++ b/tests/boundary/android/workspaceGuardianRetry.test.ts @@ -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((resolve, reject) => { + child.once("close", resolve); + child.once("error", reject); + }); + expect(code, stderr).toBe(0); + expect(JSON.parse(stdout.trim())).toMatchObject({ path: "synthetic-root" }); + }, +);