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:
parent
c45d912c91
commit
c938f97fb2
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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-" });
|
||||||
|
|
@ -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" });
|
||||||
|
},
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue