Files
rea/tests/boundary/process/jadxJavaLifecycle.test.ts
T
morluto ceeff5a417 fix(android): own Java prerequisite probing and readiness shutdown
Run the selected module-listing command through owned supervision, preserve completed module facts through finalization, and retain failed cleanup in provider/doctor scopes. Join complete availability calls on shutdown, preserve opaque interruption reasons, and validate numeric Java facts before reporting them.

Validation: 223 passing focused tests across 21 affected files, one platform skip; controlled baseline returns before child exit; real doctor cancellation leaves the selected Java group empty; nine real CLI and nine MCP tiny-APK inspections pass; six bounded MCP cost comparisons preserve normalized results. Native Windows/Linux and actual JVM failure modes remain unverified. Separate CLI exit disposition and archive failed-close findings remain open.

Closes #4845
2026-10-18 02:00:29 +08:00

495 lines
16 KiB
TypeScript

import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
import { beforeAll, expect, it as test, vi } from "vitest";
import { inspectJadxJava } from "../../../src/android/JadxJavaProbe.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import {
observeOwnedProcessGroups,
prepareProcessOwnershipInspection,
} from "../../../src/process/ProcessOwnershipObservation.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { writeFile, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createJadxProtocolFixture } from "../../fixtures/android/jadx.js";
import { inspectJadxProvider } from "../../../src/android/JadxDoctor.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
// Real idle Node commands exercise ownership; they do not establish JVM behavior.
beforeAll(() => prepareProcessOwnershipInspection());
const it = test.skipIf(process.platform === "win32");
type Launched = Awaited<ReturnType<typeof spawnOwnedProviderProcess>>;
const modules = "java.base@21\njdk.compiler@21\n";
const expectEmpty = async (launched: Launched) => {
const observed = await observeOwnedProcessGroups(launched.ownership.runId, [
launched.ownership.processGroupId,
]);
expect(observed.get(launched.ownership.processGroupId)).toEqual({
state: "empty",
});
};
it.each([
null,
{ caller: "opaque" },
new ProviderCleanupError("foreign", ["/foreign/resource"], {
reason: "foreign cleanup",
}),
])(
"joins active Java cancellation with exact opaque reason %j",
async (reason) => {
const resources = createInspectionResources();
const caller = new AbortController();
let launched: Launched | undefined;
try {
const pending = inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: caller.signal,
timeoutMs: 2500,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
'process.stdout.write("READY"); setInterval(() => {}, 1000)',
],
});
launched.process.stdout?.once("data", () => caller.abort(reason));
return launched;
},
},
);
const error: unknown = await pending.catch((cause: unknown) => cause);
expect(error).toMatchObject({
_tag: "AnalysisCancelledError",
cleanupIncomplete: false,
});
if (!(error instanceof AnalysisError) || launched === undefined)
throw new Error("Expected owned cancellation");
expect(error.cause).toBe(reason);
expect(error.cleanupResources).not.toContain("/foreign/resource");
if (reason instanceof ProviderCleanupError)
expect(reason.cleanupIncomplete).toBe(true);
await expectEmpty(launched);
} finally {
caller.abort(reason);
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
it.each([true, false])(
"keeps completed Java module facts across cleanup cancellation (valid: %s)",
async (valid) => {
const resources = createInspectionResources();
const caller = new AbortController();
const reason = { caller: "cleanup interruption" };
let launched: Launched | undefined;
try {
const result: unknown = await inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: caller.signal,
timeoutMs: 2500,
launcher: async (input) => {
const child = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
`process.stdout.write(${JSON.stringify(valid ? modules : "java.base@21\n")})`,
],
});
launched = child;
return {
...child,
cleanup: async () => {
const cleaned = await cleanupOwnedProcessGroup(child.ownership);
caller.abort(reason);
return cleaned;
},
};
},
},
).catch((cause: unknown) => cause);
if (valid) {
expect(result).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
diagnostics: {
java_module_observation: { compiler: true, major: 21 },
},
capturedOutput: { stdout: modules, truncated: false },
});
} else {
expect(result).toMatchObject({
ok: false,
error: {
code: "runtime_missing",
kind: "java_runtime",
diagnostics: { jdk_compiler: false, stdout: "java.base@21\n" },
},
});
}
if (launched === undefined) throw new Error("Expected a launched probe");
await expectEmpty(launched);
} finally {
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
it.each([true, false])(
"retains cleanup and blocks replacement probes until exact retry (valid: %s)",
async (valid) => {
const resources = createInspectionResources();
let failing = true;
let launches = 0;
let launched: Launched | undefined;
const probe = () =>
inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
timeoutMs: 2500,
launcher: async (input) => {
launches++;
const child = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
`process.stdout.write(${JSON.stringify(valid ? modules : "java.base@21\n")})`,
],
});
launched = child;
return {
...child,
cleanup: async () =>
failing
? {
cleaned: false as const,
reason: "controlled ownership verification failure",
}
: cleanupOwnedProcessGroup(child.ownership),
};
},
},
);
try {
const error: unknown = await probe().catch((cause: unknown) => cause);
if (!(error instanceof AnalysisError))
throw new Error("Expected retained cleanup error");
expect(error.cleanupIncomplete).toBe(true);
expect(error.cleanupResources.length).toBeGreaterThan(0);
if (valid)
expect(error.cleanupPrimary).toMatchObject({
diagnostics: {
java_module_observation: { major: 21, compiler: true },
},
});
else
expect(error.cleanupPrimary).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
});
const retry: unknown = await probe().catch((cause: unknown) => cause);
expect(retry).toBeInstanceOf(AnalysisError);
if (retry instanceof AnalysisError)
expect(retry.cleanupResources).toEqual(error.cleanupResources);
expect(launches).toBe(1);
failing = false;
const recovered = await probe();
expect(recovered.ok).toBe(valid);
expect(launches).toBe(2);
expect(error.cleanupIncomplete).toBe(false);
expect(error.cleanupResources).toEqual([]);
if (!valid)
expect(projectAnalysisError(error).code).toBe("capability_unavailable");
if (!valid)
expect(error.cleanupPrimary?.diagnostics).toMatchObject({
jdk_compiler: false,
phase: "module-inspection",
});
} finally {
failing = false;
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
it("uses the operation deadline's exact reason while joining the Java probe", async () => {
const resources = createInspectionResources();
const deadline = new AbortController();
const reason = new Error("whole Android operation deadline");
let launched: Launched | undefined;
try {
const outcome: unknown = await inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: deadline.signal,
operation: "inspect_android_package",
operationDeadline: { signal: deadline.signal, timeoutMs: 2000 },
timeoutMs: 2500,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
'process.stdout.write("READY"); setInterval(() => {}, 1000)',
],
});
launched.process.stdout?.once("data", () => deadline.abort(reason));
return launched;
},
},
).catch((cause: unknown) => cause);
expect(outcome).toMatchObject({
_tag: "AnalysisTimeoutError",
cause: reason,
operation: "inspect_android_package",
});
if (launched === undefined)
throw new Error("Expected owned deadline probe");
await expectEmpty(launched);
} finally {
await resources.close();
if (launched !== undefined)
expect((await cleanupOwnedProcessGroup(launched.ownership)).cleaned).toBe(
true,
);
}
});
it.each(["0", "9007199254740993", "9".repeat(400)])(
"refuses an unrepresentable Java major %s without losing its source value",
async (major) => {
const resources = createInspectionResources();
let launched: Launched | undefined;
try {
const result = await inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
timeoutMs: 2500,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
`process.stdout.write(${JSON.stringify(`java.base@${major}\njdk.compiler@21\n`)})`,
],
});
return launched;
},
},
);
expect(result).toMatchObject({
ok: false,
error: {
code: "version_unresolved",
diagnostics: { java_major: null, unparsed_java_major: major },
},
});
} finally {
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
it("joins concurrent provider readiness on shutdown while an independent doctor probe remains owned", async () => {
const f = await createJadxProtocolFixture();
const resources = createInspectionResources();
const doctor = new AbortController();
const marker = join(dirname(f.apk), "readiness-processes");
const java = join(f.environment.JAVA_HOME!, "bin", "java");
await writeFile(
java,
String.raw`#!${process.execPath}
const fs = require("node:fs");
fs.appendFileSync(${JSON.stringify(marker)}, String(process.pid) + "\n");
process.stdout.write(String(process.pid));
process.on("SIGTERM", () => {});
setTimeout(() => process.exit(99), 10000);
`,
);
const first = f.provider
.inspectAvailability()
.catch((cause: unknown) => cause);
const second = f.provider
.inspectAvailability()
.catch((cause: unknown) => cause);
let doctorSettled = false;
const independent = inspectJadxProvider(
f.environment,
resources.owner,
doctor.signal,
).then(
(value) => {
doctorSettled = true;
return value;
},
(cause: unknown) => {
doctorSettled = true;
return cause;
},
);
const pids = () =>
readFile(marker, "utf8").then(
(value) => value.trim().split("\n").map(Number),
() => [],
);
try {
await expect.poll(async () => (await pids()).length).toBe(3);
await f.provider.close();
const cancelled = await Promise.all([first, second]);
for (const error of cancelled)
expect(error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(doctorSettled).toBe(false);
doctor.abort("stop independent doctor");
expect(await independent).toMatchObject({
_tag: "AnalysisCancelledError",
cause: "stop independent doctor",
});
for (const pid of await pids()) {
// Observation only; cleanup remains with each command's run identity.
expect(() => process.kill(pid, 0)).toThrowError(
expect.objectContaining({ code: "ESRCH" }),
);
}
} finally {
doctor.abort();
await f.provider.close();
await resources.close();
await Promise.all([first, second, independent]);
}
});
it("refuses a root's valid listing until an inherited descendant pipe is finalized", async () => {
const resources = createInspectionResources();
const root = await createTestTempDirectory("rea-jadx-readiness-descendant-");
const release = join(root, "release");
const child = String.raw`const fs = require("node:fs"); process.stdout.write("DESCENDANT_READY\n");
setInterval(() => { if (fs.existsSync(${JSON.stringify(release)})) process.exit(0); }, 50);
setTimeout(() => process.exit(92), 10000);`;
let launched: Launched | undefined;
try {
const result = await inspectJadxJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
timeoutMs: 1000,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
String.raw`
const child = require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(child)}], {detached: true, stdio: ["ignore", "inherit", "inherit"]});
process.stdout.write(${JSON.stringify(modules)} + "DESCENDANT_GROUP:" + child.pid + "\n"); child.unref();
`,
],
});
return launched;
},
},
);
expect(result).toMatchObject({
ok: false,
error: {
code: "version_unresolved",
diagnostics: { failure: "probe_terminated", exit_code: 0 },
},
});
if (result.ok || launched === undefined)
throw new Error("Expected incomplete-output deadline");
const group = Number(
/DESCENDANT_GROUP:(\d+)/u.exec(
String(result.error.diagnostics.stdout),
)?.[1],
);
expect(Number.isSafeInteger(group)).toBe(true);
const observed = await observeOwnedProcessGroups(launched.ownership.runId, [
group,
]);
expect(observed.get(group)).toEqual({ state: "empty" });
await expectEmpty(launched);
} finally {
await writeFile(release, "release");
await resources.close();
if (launched !== undefined)
expect(
(
await (launched.cleanup?.() ??
cleanupOwnedProcessGroup(launched.ownership))
).cleaned,
).toBe(true);
}
});
it("joins the whole availability call while its final archive close is held", async () => {
const f = await createJadxProtocolFixture();
let started: () => void = () => {};
const closingArchive = new Promise<void>((resolve) => {
started = resolve;
});
let release: () => void = () => {};
const held = new Promise<void>((resolve) => {
release = resolve;
});
const original = ZipArtifactReader.prototype.close;
const archive = vi
.spyOn(ZipArtifactReader.prototype, "close")
.mockImplementationOnce(async function (this: ZipArtifactReader) {
started();
await held;
await original.call(this);
});
const availability = f.provider.inspectAvailability();
let completed = false;
let close: Promise<void> | undefined;
try {
await closingArchive;
close = f.provider.close().then(() => {
completed = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(completed).toBe(false);
release();
await close;
expect(await availability).toMatchObject({ status: "available" });
} finally {
release();
archive.mockRestore();
await availability.catch(() => undefined);
await close;
await f.provider.close();
}
});