chore(release): preserve concurrent main updates

This commit is contained in:
morluto 2026-10-09 09:17:44 +08:00
commit 2609352eef
9 changed files with 180 additions and 17 deletions

View File

@ -23,6 +23,14 @@ export REA_JADX_MCP_JAR=/absolute/path/jadx-headless-mcp-0.7.1-all.jar
export JAVA_HOME=/absolute/path/existing-jdk
```
Readiness checks the selected JAR's ZIP directory for the classes consumed by
REA's metadata bridge, as well as Java's compiler and version modules. It does
not load an APK or execute engine code. Invalid archives and missing bridge
classes report JAR-specific recovery; classpath inventory alone does not prove
that every class or engine operation will load successfully. Discovery limits
each ZIP metadata read to 8 MiB before allocation; a larger directory reports
the exhausted read budget and the selected JAR path.
The current metadata bridge is verified on macOS arm64 with OpenJDK 21 and the
public Appium ApiDemos fixture. The POSIX adapter also supports Linux; the new
bridge has not yet undergone real verification on Linux/JDK 25.

View File

@ -1,5 +1,12 @@
import assert from "node:assert/strict";
import { access, mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
import {
access,
mkdtemp,
mkdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, dirname, isAbsolute, join } from "node:path";
import { Client } from "@modelcontextprotocol/client";
@ -67,6 +74,8 @@ export async function verifyAndroidFailureRecovery({
}
const withoutJavaHome = { ...environment };
delete withoutJavaHome.JAVA_HOME;
const invalidJar = join(root, "invalid.jar");
await writeFile(invalidJar, "not a Java archive");
const environments = [
{
label: "JRE without compiler",
@ -78,6 +87,12 @@ export async function verifyAndroidFailureRecovery({
env: { ...withoutJavaHome, PATH: empty },
observation: "ENOENT",
},
{
label: "invalid engine archive with a working JDK",
env: { ...environment, REA_JADX_MCP_JAR: invalidJar },
observation: "Cannot inspect REA_JADX_MCP_JAR",
jarFailure: true,
},
];
if (process.platform === "darwin") {
// /usr/bin/java either resolves an installed runtime or reports the actual stub failure.
@ -114,8 +129,16 @@ export async function verifyAndroidFailureRecovery({
JSON.stringify(failure).includes(scenario.observation),
JSON.stringify(failure),
);
assert.match(failure.message, /full JDK/u);
assert.match(failure.remediation.action, /java --list-modules/u);
assert.match(
failure.message,
scenario.jarFailure ? /REA_JADX_MCP_JAR/u : /full JDK/u,
);
assert.match(
failure.remediation.action,
scenario.jarFailure ? /jadx-headless-mcp/u : /java --list-modules/u,
);
if (scenario.jarFailure)
assert.doesNotMatch(failure.message, /Select a full JDK/u);
assert.doesNotMatch(JSON.stringify(failure), /rea doctor/u);
await verifyAndroidReadiness({
entrypoint,

View File

@ -5,6 +5,7 @@ import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
import {
execFileOutput,
execFileOutputFailure,
@ -13,6 +14,9 @@ import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
// Bound ZIP metadata allocation during discovery. The audited 0.7.1 JAR's
// central directory occupies 2,294,350 bytes; class bodies are not read here.
const JAR_METADATA_READ_MAX_BYTES = 8 * 1024 * 1024;
/** Caller-supplied tools, resolved only when an Android operation is selected. */
export interface JadxConfiguration {
@ -116,9 +120,53 @@ const readJadxConfiguration = async (
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
const jvmArguments = resolveJvmArguments(environment);
await inspectJavaRuntime(java, environment, signal);
await requireEngineArchive(jar, signal);
return { jar, java, jvmArguments };
};
const requireEngineArchive = async (
jar: string,
signal?: AbortSignal,
): Promise<void> => {
// These classes are consumed directly by ReaJadxBridge.java. This checks the
// classpath inventory without loading engine code or starting a JADX session.
const missing = new Set([
"com/atxx/jhmcp/JadxSession.class",
"com/atxx/jhmcp/SessionHolder.class",
"jadx/api/JadxDecompiler.class",
"com/google/gson/Gson.class",
"io/modelcontextprotocol/kotlin/sdk/server/Server.class",
]);
const reader = new ZipArtifactReader(jar, "zip", JAR_METADATA_READ_MAX_BYTES);
try {
for await (const entry of reader.entries(signal)) {
if (entry.kind === "file" && !entry.encrypted) missing.delete(entry.path);
if (missing.size === 0) return;
}
} catch (cause) {
if (signal?.aborted === true) throw cause;
const reason = cause instanceof Error ? cause.message : String(cause);
throw configurationFailure(
"version_unresolved",
`Cannot inspect REA_JADX_MCP_JAR ${jar} as a Java archive: ${reason}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`,
{
jar_path: jar,
phase: "jar-inspection",
archive_error: reason,
maximum_metadata_read_bytes: JAR_METADATA_READ_MAX_BYTES,
},
cause,
);
} finally {
await reader.close();
}
throw configurationFailure(
"unsupported_version",
`REA_JADX_MCP_JAR ${jar} does not contain the required metadata bridge classes: ${[...missing].join(", ")}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`,
{ jar_path: jar, phase: "jar-inspection", missing_classes: [...missing] },
);
};
const requireSupportedHost = (): void => {
if (process.platform === "linux" || process.platform === "darwin") return;
throw configurationFailure(

View File

@ -14,7 +14,10 @@ import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
class NodeFileReader extends Reader<string> {
#handle: FileHandle | undefined;
constructor(private readonly path: string) {
constructor(
private readonly path: string,
private readonly maximumReadBytes?: number,
) {
super(path);
}
@ -29,7 +32,16 @@ class NodeFileReader extends Reader<string> {
): Promise<Uint8Array> {
const handle = this.#handle;
if (handle === undefined) throw new Error("ZIP reader is not initialized");
const bytes = Buffer.alloc(Math.min(length, this.size - index));
const readBytes = Math.min(length, this.size - index);
if (
this.maximumReadBytes !== undefined &&
readBytes > this.maximumReadBytes
)
throw new ArtifactReaderFailure(
"limit",
`ZIP metadata read requires ${readBytes} bytes, exceeding the ${this.maximumReadBytes}-byte read budget`,
);
const bytes = Buffer.alloc(readBytes);
const read = await handle.read(bytes, 0, bytes.length, index);
return bytes.subarray(0, read.bytesRead);
}
@ -47,9 +59,14 @@ export class ZipArtifactReader implements ArtifactReader {
readonly #reader: ZipReader<string>;
readonly #entries = new Map<string, Entry>();
constructor(path: string, format: ZipPackageFormat) {
/** Optionally bound each metadata read before allocating its backing buffer. */
constructor(
path: string,
format: ZipPackageFormat,
maximumMetadataReadBytes?: number,
) {
this.format = format;
this.#source = new NodeFileReader(path);
this.#source = new NodeFileReader(path, maximumMetadataReadBytes);
this.#reader = new ZipReader(this.#source, {
checkSignature: true,
checkOverlappingEntry: true,

View File

@ -9,14 +9,40 @@ import {
} from "../../../src/android/JadxConfiguration.js";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
const it = test.skipIf(process.platform === "win32");
it("rejects malformed or unrelated engine archives with JAR-specific recovery", async () => {
const root = await createTestTempDirectory("rea-jadx-invalid-jar-");
const javaHome = await createFakeJdk(root);
const jar = join(root, "engine.jar");
for (const malformed of [true, false]) {
if (malformed) await writeFile(jar, "not a Java archive");
else await writeOrderedZip(jar, ["unrelated/Library.class"]);
const environment = { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome };
const availability = await inspectJadxAvailability(environment);
expect(availability).toMatchObject({
status: "unavailable",
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" },
});
await expect(
resolveJadxConfiguration(environment, "inspect_android_package"),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
});
expect(availability.reason).not.toContain("Select a full JDK");
}
});
it("rejects an explicit JAVA_HOME without an executable java binary", async () => {
const root = await createTestTempDirectory("rea-jadx-config-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = join(root, "missing-jdk");
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await expect(
resolveJadxConfiguration(
@ -33,7 +59,7 @@ it("accepts a full JDK and canonicalizes the selected JAR", async () => {
const root = await createTestTempDirectory("rea-jadx-config-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = await createFakeJdk(root);
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await expect(
resolveJadxConfiguration(
@ -51,7 +77,7 @@ it("configures heap and visible processors independently without forcing either"
const root = await createTestTempDirectory("rea-jadx-config-");
const jar = join(root, "engine.jar");
const javaHome = await createFakeJdk(root);
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
const configuration = await resolveJadxConfiguration(
{
REA_JADX_MCP_JAR: jar,
@ -98,7 +124,7 @@ it("configures heap and visible processors independently without forcing either"
it("distinguishes a runnable JRE from a missing Java executable", async () => {
const root = await createTestTempDirectory("rea-jadx-runtime-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
const jreHome = await createFakeJdk(root, false);
await expect(
resolveJadxConfiguration(
@ -142,7 +168,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
const bin = join(javaHome, "bin");
const java = join(bin, "java");
await mkdir(bin, { recursive: true });
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await writeFile(
java,
`#!/bin/sh\nexec ${process.execPath} -e 'process.stdout.write("x".repeat(2 * 1024 * 1024))'\n`,
@ -186,7 +212,7 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
it("distinguishes an unreported Java version from an unsupported old version", async () => {
const root = await createTestTempDirectory("rea-jadx-version-listing-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
const unresolvedHome = await createFakeJdk(root);
await writeFile(
@ -233,7 +259,7 @@ it("cancels an in-flight Java readiness probe", async () => {
const java = join(bin, "java");
const marker = join(root, "java-probe-started");
await mkdir(bin, { recursive: true });
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await writeFile(
java,
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,
@ -269,7 +295,7 @@ it("reports a killed Java probe as unresolved instead of missing runtime", async
const java = join(bin, "java");
const marker = join(root, "java-probe-started");
await mkdir(bin, { recursive: true });
await writeFile(jar, "fixture jar");
await writeJadxJarInventory(jar);
await writeFile(
java,
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,

View File

@ -9,8 +9,32 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
it("bounds a requested metadata read without rejecting the underlying valid ZIP", async () => {
const root = await createTestTempDirectory("rea-zip-metadata-budget-");
const path = join(root, "large-directory.zip");
const name = `${"a".repeat(8192)}.class`;
await writeOrderedZip(path, [name]);
const bounded = new ZipArtifactReader(path, "zip", 4096);
const control = new ZipArtifactReader(path, "zip");
const collect = async (reader: ZipArtifactReader): Promise<string[]> => {
const names: string[] = [];
for await (const entry of reader.entries()) names.push(entry.path);
return names;
};
try {
await expect(collect(bounded)).rejects.toMatchObject({
reason: "limit",
message: expect.stringContaining("4096-byte read budget"),
});
await expect(collect(control)).resolves.toEqual([name]);
} finally {
await Promise.all([bounded.close(), control.close()]);
}
});
it("rejects overlapping ZIP member data ranges through artifact inventory", async () => {
const root = await createTestTempDirectory("rea-zip-overlap-");
const path = join(root, "overlap.zip");

View File

@ -13,6 +13,7 @@ import {
createJadxProtocolFixture as setup,
verifyJadxFixtureCleanup as verifyCleanup,
} from "../../fixtures/android/jadx.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
const it = test.skipIf(process.platform === "win32");
@ -104,7 +105,7 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async (
const changed = await inspect();
expect(changed.subject?.digest.sha256).not.toBe(first.subject?.digest.sha256);
await verifyCleanup(launches.slice(0, 1));
await writeFile(jar, "new engine bytes");
await writeJadxJarInventory(jar, ["fixture/NewEngineMarker.class"]);
await inspect();
environment.JAVA_TOOL_OPTIONS = "-Xmx8g";
environment._JAVA_OPTIONS = "-XX:+UseG1GC";

View File

@ -12,6 +12,7 @@ import {
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import { writeOrderedZip } from "../artifactEntryOrder.js";
import { createTestTempDirectory } from "../temporaryDirectory.js";
import { writeJadxJarInventory } from "./jadxJar.js";
const fixture = fileURLToPath(new URL("./jadx-mcp.mjs", import.meta.url));
@ -30,7 +31,7 @@ export const createJadxProtocolFixture = async (mode = "normal") => {
);
await chmod(java, 0o755);
await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]);
await writeFile(jar, "synthetic engine bytes; not a Java archive");
await writeJadxJarInventory(jar);
const launches: {
runId: string;
cwd: string | undefined;

15
tests/fixtures/android/jadxJar.ts vendored Normal file
View File

@ -0,0 +1,15 @@
import { writeOrderedZip } from "../artifactEntryOrder.js";
/** ZIP classpath inventory for synthetic protocol tests; not executable bytecode. */
export const writeJadxJarInventory = (
path: string,
extraEntries: readonly string[] = [],
): Promise<void> =>
writeOrderedZip(path, [
"com/atxx/jhmcp/JadxSession.class",
"com/atxx/jhmcp/SessionHolder.class",
"jadx/api/JadxDecompiler.class",
"com/google/gson/Gson.class",
"io/modelcontextprotocol/kotlin/sdk/server/Server.class",
...extraEntries,
]);