fix(android): support owned JADX stdio on Windows (#1276)

Support owned JADX stdio on Windows x64, preserve protocol input errors through cancellation, and verify native stdin and Android CLI/MCP lifecycle on the real Windows host.

Closes #1262
This commit is contained in:
Ysopwnable 2026-10-09 20:34:42 +08:00 committed by GitHub
parent b71a591a60
commit 636b245f1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 954 additions and 49 deletions

View File

@ -157,7 +157,7 @@ Additional tools and host support depend on the target:
| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://rea.tools/guides/browser/) |
| Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) |
| .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | Static inspection; [managed-code guide](docs/managed-code-analysis.md) |
| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS; [Android guide](docs/android-analysis.md) |
| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) |
| Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) |
| Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) |
| Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) |

View File

@ -23,6 +23,23 @@ export REA_JADX_MCP_JAR=/absolute/path/jadx-headless-mcp-0.7.1-all.jar
export JAVA_HOME=/absolute/path/existing-jdk
```
On Windows x64, use a REA package with its matching bundled Windows native
controls **including owned protocol stdin support**. This Windows boundary is
new on repository main; an older released package can still reject this host.
PowerShell accepts paths containing spaces without additional escaping
inside these environment values:
```powershell
$env:REA_JADX_MCP_JAR = 'D:\tools\jadx-headless-mcp-0.7.1-all.jar'
$env:JAVA_HOME = 'D:\tools\jdk-21'
rea inspect-android-package 'D:\targets\Example.apk'
```
REA selects `JAVA_HOME\bin\java.exe`, or resolves `java.exe` on the selected
Windows `PATH`. A missing or mismatched native artifact reports the package
prerequisite rather than attempting an unowned Java launch. Windows arm64 is
outside this provider's current native boundary.
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
@ -34,7 +51,9 @@ 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.
Windows is unsupported for this provider's owned stdio process boundary.
Windows x64 uses an owned Job Object with bidirectional stdio, atomic job
assignment and kill-on-owner-close. Real Windows verification uses JDK 21 and
the pinned JADX 0.7.1 JAR; see the real verification lane below.
## Answer an analyst question
@ -138,6 +157,12 @@ target/configuration changes, failure, timeout, cancellation, MCP disconnect or
server shutdown. One-shot CLI commands always join cleanup before returning.
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.
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
`java --list-modules` to confirm `jdk.compiler` is present. A runnable JRE alone
@ -197,6 +222,12 @@ without the compiler module. It checks real Java startup failures, missing
selectors, recovery after failures, and CLI cancellation with owned cleanup.
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.
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
`_reference/` and are excluded from package contents. The lane exercises actual

View File

@ -463,6 +463,13 @@ Electron GUI behavior or another engine.
## Android APK analysis
The real Android lane also runs on Windows x64 with its matching bundled native
controls. It verifies bidirectional protocol input, CLI/MCP result parity,
real MCP cancellation/disconnect and Java exit after abrupt CLI owner
termination. The forced Windows exit does not exercise the POSIX SIGTERM
handler and can leave temporary workspace files. `verify:windows-native`
separately checks binary input, backpressure, EOF and pending-write job closure.
`npm run verify:android` requires an existing Java 17+ and an explicit
`REA_JADX_MCP_JAR` for jadx-headless-mcp 0.7.1. Set `REA_ANDROID_TEST_APK` to the
fixed public ApiDemos v6.0.18 fixture. Obtain both with the explicit

View File

@ -94,7 +94,8 @@ struct Runtime : Resource {
Runtime() : Resource(Kind::Runtime) {}
};
struct Process : Resource {
Handle job, process, stdoutRead, stderrRead;
Handle job, process, stdoutRead, stderrRead, stdinWrite;
bool inputPending = false;
DWORD pid = 0;
Process() : Resource(Kind::Process) {}
};
@ -122,7 +123,8 @@ void closeRuntime(Runtime& runtime);
napi_value filesystemCall(napi_env env, const std::wstring& operation,
const std::vector<napi_value>& args);
std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::wstring& commandLine,
const std::wstring& cwd, const std::vector<std::wstring>& environment);
const std::wstring& cwd, const std::vector<std::wstring>& environment,
bool protocolStdin = false);
napi_value processCall(napi_env env, const std::wstring& operation,
const std::vector<napi_value>& args);
napi_value inspect(napi_env env);

View File

@ -34,16 +34,17 @@ static Handle createJob() {
return job;
}
static void pipe(Handle& read, Handle& write) {
static void pipe(Handle& read, Handle& write, bool parentReads = true) {
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE rawRead, rawWrite;
require(CreatePipe(&rawRead, &rawWrite, &attributes, 0), "Create owned process output pipe failed");
read.reset(rawRead); write.reset(rawWrite);
require(SetHandleInformation(read.get(), HANDLE_FLAG_INHERIT, 0), "Protect process output handle inheritance failed");
require(SetHandleInformation(parentReads ? read.get() : write.get(), HANDLE_FLAG_INHERIT, 0), "Protect parent process pipe handle inheritance failed");
}
std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::wstring& commandLine,
const std::wstring& cwd, const std::vector<std::wstring>& environment) {
const std::wstring& cwd, const std::vector<std::wstring>& environment,
bool protocolStdin) {
const auto executable = ordinaryDriveSeparators(command);
require(executable.size() >= 3 && executable[1] == L':' && executable[2] == L'\\',
"Owned Windows process requires an absolute executable path", command, ERROR_INVALID_PARAMETER);
@ -52,9 +53,13 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
Handle stdoutWrite, stderrWrite;
pipe(result->stdoutRead, stdoutWrite); pipe(result->stderrRead, stderrWrite);
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
Handle input(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
&security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
require(input.valid(), "Open owned process null input failed");
Handle input;
if (protocolStdin) pipe(input, result->stdinWrite, false);
else {
input.reset(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
&security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
require(input.valid(), "Open owned process null input failed");
}
Attributes attributes(2);
HANDLE job = result->job.get();
attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &job, sizeof(job));
@ -198,16 +203,93 @@ static napi_value callerToken(napi_env env) {
return result;
}
struct InputWork {
napi_async_work work = nullptr;
napi_deferred deferred = nullptr;
napi_ref processReference = nullptr;
Process* process;
Handle pipe;
std::vector<unsigned char> bytes;
size_t written = 0;
std::unique_ptr<Failure> failure;
explicit InputWork(Process& process) : process(&process) {}
};
static void writeInput(napi_env, void* pointer) {
auto& work = *static_cast<InputWork*>(pointer);
try {
// Anonymous pipe writes may block. Keep them off the JS thread so output
// polling and job termination remain live while a child stops reading.
while (work.written < work.bytes.size()) {
DWORD written = 0;
const auto count = static_cast<DWORD>(std::min<size_t>(65536, work.bytes.size() - work.written));
require(WriteFile(work.pipe.get(), work.bytes.data() + work.written, count, &written, nullptr),
"Write owned process input failed");
require(written > 0, "Owned process input write made no progress", L"", ERROR_WRITE_FAULT);
work.written += written;
}
} catch (const Failure& failure) {
work.failure = std::make_unique<Failure>(failure);
} catch (const std::exception& failure) {
work.failure = std::make_unique<Failure>(failure.what(), L"", ERROR_GEN_FAILURE);
}
}
static void completeInput(napi_env env, napi_status status, void* pointer) {
std::unique_ptr<InputWork> work(static_cast<InputWork*>(pointer));
work->process->inputPending = false;
if (work->failure) napi_reject_deferred(env, work->deferred, failureValue(env, *work->failure));
else if (status != napi_ok)
napi_reject_deferred(env, work->deferred, failureValue(env, Failure("Owned process input worker was cancelled", L"", ERROR_OPERATION_ABORTED)));
else napi_resolve_deferred(env, work->deferred, number(env, work->written));
napi_delete_reference(env, work->processReference);
napi_delete_async_work(env, work->work);
}
static napi_value inputWrite(napi_env env, napi_value owner, Process& process, napi_value input) {
require(process.stdinWrite.valid(), "Owned process has no writable input", L"", ERROR_BROKEN_PIPE);
require(!process.inputPending, "Owned process input already has a pending write", L"", ERROR_BUSY);
auto work = std::make_unique<InputWork>(process);
void* bytes = nullptr;
size_t size = 0;
bool buffer = false;
check(napi_is_buffer(env, input, &buffer));
require(buffer, "Owned process input must be a Buffer", L"", ERROR_INVALID_PARAMETER);
check(napi_get_buffer_info(env, input, &bytes, &size));
if (size > 0) work->bytes.assign(static_cast<unsigned char*>(bytes), static_cast<unsigned char*>(bytes) + size);
HANDLE pipe;
require(DuplicateHandle(GetCurrentProcess(), process.stdinWrite.get(), GetCurrentProcess(), &pipe,
0, FALSE, DUPLICATE_SAME_ACCESS), "Retain owned process input handle failed");
work->pipe.reset(pipe);
napi_value promise;
check(napi_create_promise(env, &work->deferred, &promise));
check(napi_create_reference(env, owner, 1, &work->processReference));
try {
check(napi_create_async_work(env, nullptr, string(env, std::string("REA owned process input")),
writeInput, completeInput, work.get(), &work->work));
check(napi_queue_async_work(env, work->work));
process.inputPending = true;
} catch (...) {
if (work->work) napi_delete_async_work(env, work->work);
napi_delete_reference(env, work->processReference);
throw;
}
work.release();
return promise;
}
napi_value processCall(napi_env env, const std::wstring& operation, const std::vector<napi_value>& args) {
if (operation == L"process_caller_token") {
require(args.empty(), "Caller token observation takes no arguments", L"", ERROR_INVALID_PARAMETER);
return callerToken(env);
}
if (operation == L"process_spawn") {
require(args.size() == 4, "Wrong native process launch argument count", L"", ERROR_INVALID_PARAMETER);
require(args.size() == 4 || args.size() == 5, "Wrong native process launch argument count", L"", ERROR_INVALID_PARAMETER);
bool protocolStdin = false;
if (args.size() == 5) check(napi_get_value_bool(env, args[4], &protocolStdin));
std::vector<std::wstring> environment;
for (const auto& entry : array(env, args[3])) environment.push_back(wide(env, entry));
auto process = spawnProcess(wide(env, args[0]), wide(env, args[1]), wide(env, args[2]), environment);
auto process = spawnProcess(wide(env, args[0]), wide(env, args[1]), wide(env, args[2]), environment, protocolStdin);
auto result = object(env);
set(env, result, "pid", number(env, process->pid));
set(env, result, "jobAssigned", boolean(env, true));
@ -215,8 +297,16 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v
set(env, result, "handle", wrap(env, std::move(process)));
return result;
}
if (operation == L"process_stdin_write") {
require(args.size() == 2, "Wrong native process input argument count", L"", ERROR_INVALID_PARAMETER);
return inputWrite(env, args[0], static_cast<Process&>(resource(env, args[0], Kind::Process)), args[1]);
}
require(args.size() == 1, "Wrong native process handle argument count", L"", ERROR_INVALID_PARAMETER);
auto& process = static_cast<Process&>(resource(env, args[0], Kind::Process));
if (operation == L"process_stdin_close") {
process.stdinWrite.reset();
return null(env);
}
if (operation == L"process_poll") {
auto result = object(env);
bool stdoutEnded = false, stderrEnded = false;
@ -240,7 +330,7 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v
}
if (operation == L"process_close") {
// Closing the job is the crash-safe authority; no PID enumeration is used.
process.job.reset(); process.process.reset(); process.stdoutRead.reset(); process.stderrRead.reset();
process.job.reset(); process.process.reset(); process.stdoutRead.reset(); process.stderrRead.reset(); process.stdinWrite.reset();
process.closed = true;
return null(env);
}
@ -266,6 +356,7 @@ napi_value inspect(napi_env env) {
set(env, result, "privateDacl", boolean(env, true));
set(env, result, "atomicJobAssignment", boolean(env, true));
set(env, result, "killOnOwnerClose", boolean(env, true));
set(env, result, "protocolStdin", boolean(env, true));
return result;
}

View File

@ -0,0 +1,237 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
/** Exercise owned Windows stdin through its production Writable and native boundary. */
export async function verifyWindowsProtocolInput(packageRoot, workspace) {
const { WindowsOwnedProcess } = await import(
pathToFileURL(join(packageRoot, "dist/windows/WindowsOwnedProcess.js"))
);
const { requireWindowsNativeAuthority } = await import(
pathToFileURL(join(packageRoot, "dist/windows/WindowsNativeLoader.js"))
);
const native = requireWindowsNativeAuthority();
assert.equal(native.inspection.protocolStdin, true);
const launch = (script) => acquire(WindowsOwnedProcess, workspace, script);
const echo = await verifyEcho(launch);
const cancellation = await verifyBlockedCancellation(launch);
await verifyNativePendingWrite(native, workspace);
const { JadxMcpTransport } = await import(
pathToFileURL(join(packageRoot, "dist/android/JadxMcpTransport.js"))
);
await verifyTransportPendingWrite(JadxMcpTransport, workspace);
return {
echo,
cancellation,
native_pending_write_closed: true,
forged_input_handle_rejected: true,
malformed_input_rejected: true,
provider_transport_pending_write_cancelled: true,
};
}
function acquire(Process, workspace, script) {
const child = new Process(
process.execPath,
["-e", script],
workspace,
process.env,
false,
true,
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (bytes) => {
stdout += bytes.toString();
});
child.stderr.on("data", (bytes) => {
stderr += bytes.toString();
});
const inputErrors = [];
child.stdin.on("error", (cause) => inputErrors.push(cause.message));
const closed = new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve({ code, stdout, stderr }));
});
return { child, closed, inputErrors, stdout: () => stdout };
}
async function verifyEcho(launch) {
const acquired = launch(`
const {createHash} = require('node:crypto');
const hash = createHash('sha256'); let count = 0;
process.stdin.on('data', bytes => { hash.update(bytes); count += bytes.length; });
process.stdin.pause(); setTimeout(() => process.stdin.resume(), 250);
process.stdin.on('end', () => console.log(JSON.stringify({count,sha256:hash.digest('hex')})));
`);
const bytes = Buffer.from(
"caller-selected UTF8: 路径 Ω; binary:\0\n".repeat(60000),
);
const expected = {
count: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
};
let ticks = 0;
const timer = setInterval(() => ticks++, 20);
try {
let writable;
await new Promise((resolve, reject) => {
writable = acquired.child.stdin.write(bytes, (cause) =>
cause ? reject(cause) : resolve(),
);
// The native worker owns its copy before returning to the caller.
bytes.fill(0x5a);
});
assert.equal(
writable,
false,
"Large protocol input did not expose backpressure",
);
assert.ok(ticks > 0, "Input write blocked the JS event loop");
acquired.child.stdin.end();
const result = await deadline(acquired.closed);
assert.equal(result.code, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), expected);
assert.deepEqual(acquired.inputErrors, []);
assert.equal((await acquired.child.cleanup()).cleaned, true);
return {
bytes: expected.count,
sha256: expected.sha256,
backpressure: true,
event_loop_responsive: true,
eof_observed: true,
};
} finally {
clearInterval(timer);
await acquired.child.cleanup();
}
}
async function verifyBlockedCancellation(launch) {
const acquired = launch(`
const {spawn} = require('node:child_process');
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:'inherit'});
console.log('owned descendant ' + child.pid);
setInterval(() => {}, 1000);
`);
try {
for (
let attempt = 0;
attempt < 100 && !acquired.stdout().includes("owned descendant");
attempt++
)
await delay(25);
assert.match(acquired.stdout(), /owned descendant \d+/u);
const write = new Promise((resolve) =>
acquired.child.stdin.write(Buffer.alloc(2 * 1024 * 1024), (cause) =>
resolve(cause),
),
);
await delay(100);
const cleanup = await acquired.child.cleanup();
assert.equal(cleanup.cleaned, true, cleanup.reason);
assert.ok(
await deadline(write),
"Closing the owned job reported a successful blocked write",
);
await deadline(acquired.closed);
return { blocked_write_cancelled: true, descendant_job_settled: true };
} finally {
await acquired.child.cleanup();
}
}
async function verifyNativePendingWrite(native, workspace) {
const quote = (value) =>
`"${value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\+)$/u, "$1$1")}"`;
const child = native.call("process_spawn", [
process.execPath,
[process.execPath, "-e", "setInterval(() => {}, 1000)"]
.map(quote)
.join(" "),
workspace,
Object.entries(process.env)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${value}`),
true,
]);
let rejected;
try {
assert.throws(
() => native.call("process_stdin_write", [child.handle, "not a Buffer"]),
/must be a Buffer/u,
);
const pending = native.call("process_stdin_write", [
child.handle,
Buffer.alloc(2 * 1024 * 1024),
]);
rejected = pending.then(
() => false,
(cause) => cause,
);
assert.throws(
() =>
native.call("process_stdin_write", [
child.handle,
Buffer.from("second"),
]),
/pending write/u,
);
assert.throws(
() => native.call("process_stdin_write", [{}, Buffer.from("forged")]),
/does not belong to REA/u,
);
} finally {
// The worker retains its handle/buffer after the JS resource closes.
native.call("process_close", [child.handle]);
}
const failure = await deadline(rejected);
assert.ok(failure instanceof Error);
assert.match(failure.message, /Write owned process input failed/u);
}
async function verifyTransportPendingWrite(Transport, workspace) {
const transport = new Transport({
command: process.execPath,
arguments: ["-e", "setInterval(() => {}, 1000)"],
cwd: workspace,
});
transport.onerror = () => {};
try {
await transport.start();
const pending = transport
.send({
jsonrpc: "2.0",
id: "blocked",
method: "probe",
params: { payload: "x".repeat(2 * 1024 * 1024) },
})
.then(
() => false,
(cause) => cause,
);
await delay(100);
await transport.close();
assert.ok(
await deadline(pending),
"Transport cancellation accepted a blocked write",
);
} finally {
await transport.close();
}
}
async function deadline(promise) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error("Owned input fixture exceeded its deadline")),
10000,
);
}),
]).finally(() => clearTimeout(timer));
}

View File

@ -17,6 +17,7 @@ import {
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { verifyWindowsProtocolInput } from "./lib/windows-protocol-input.mjs";
if (process.platform !== "win32")
throw new Error(
@ -411,6 +412,10 @@ setInterval(()=>{},1000);\n`,
await delay();
assert.equal(live(ownerClose.pid), false);
report.processes.killOnOwnerClose = true;
report.processes.protocolInput = await verifyWindowsProtocolInput(
packageRoot,
workspace,
);
const exited = launch([
"--max-old-space-size=128",
"-e",

View File

@ -13,6 +13,8 @@ import fixture from "../../fixtures/android/apidemos.json" with { type: "json" }
import { verifyAndroidCliCancellation } from "./cli-cancellation.mjs";
import { verifyAndroidMetadata } from "./metadata.mjs";
import { verifyAndroidFailureRecovery } from "./failure-recovery.mjs";
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
import { verifyWindowsAndroidLifecycle } from "./windows-lifecycle.mjs";
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const apk = process.env.REA_ANDROID_TEST_APK;
@ -33,10 +35,10 @@ assert.equal(
JADX_RELEASE.sha256,
"Real Android lane requires the audited headless JADX release JAR",
);
const java =
process.env.JAVA_HOME === undefined
? "java"
: resolve(process.env.JAVA_HOME, "bin/java");
const { java, jvmArguments } = await resolveJadxConfiguration(
process.env,
"inspect_android_package",
);
const execute = promisify(execFile);
await execute(java, ["-version"], { timeout: 10_000 });
const entrypoint =
@ -49,6 +51,7 @@ await verifyAndroidMetadata({
apk: path,
repository,
execute,
jvmArguments,
bridge: resolve(dirname(entrypoint), "../bridge/android/ReaJadxBridge.java"),
});
const tasks = [
@ -200,15 +203,22 @@ assert.ok(
reference.dex_offset === null && reference.source_line === null,
),
);
await verifyAndroidCliCancellation({
const lifecycleOptions = {
entrypoint,
environment,
path,
repository,
execute,
});
};
const windowsLifecycle =
process.platform === "win32"
? await verifyWindowsAndroidLifecycle(lifecycleOptions)
: undefined;
if (windowsLifecycle === undefined)
await verifyAndroidCliCancellation(lifecycleOptions);
await verifyAndroidFailureRecovery({
java,
jvmArguments,
entrypoint,
environment,
path,
@ -230,7 +240,10 @@ const report = {
cli_mcp_parity: true,
original_apk_unchanged: true,
apk_executed: false,
cli_sigterm_cleanup: true,
cli_sigterm_cleanup: process.platform !== "win32",
...(windowsLifecycle === undefined
? {}
: { windows_lifecycle: windowsLifecycle }),
metadata_without_code_generation: true,
stable_overload_selection: true,
};

View File

@ -16,6 +16,7 @@ import { TOOL_CONTRACTS } from "../../../dist/contracts/toolContracts.js";
/** Real unusable Java runtimes and selectors must lead to applicable recovery. */
export async function verifyAndroidFailureRecovery({
java,
jvmArguments = [],
entrypoint,
environment,
path,
@ -24,6 +25,8 @@ export async function verifyAndroidFailureRecovery({
fixture,
}) {
const root = await mkdtemp(join(tmpdir(), "rea-android-recovery-"));
const javaName = process.platform === "win32" ? "java.exe" : "java";
const jlinkName = process.platform === "win32" ? "jlink.exe" : "jlink";
try {
const withoutJar = { ...environment };
delete withoutJar.REA_JADX_MCP_JAR;
@ -36,10 +39,10 @@ export async function verifyAndroidFailureRecovery({
});
let jlink;
for (const candidate of isAbsolute(java)
? [join(dirname(java), "jlink")]
? [join(dirname(java), jlinkName)]
: (environment.PATH ?? "")
.split(delimiter)
.map((directory) => join(directory, "jlink"))) {
.map((directory) => join(directory, jlinkName))) {
try {
await access(candidate);
jlink = candidate;
@ -53,10 +56,22 @@ export async function verifyAndroidFailureRecovery({
"Android failure-recovery verification requires the full JDK's jlink via JAVA_HOME or PATH.",
);
const jre = join(root, "jre");
await execute(jlink, ["--add-modules", "java.base", "--output", jre], {
timeout: 60_000,
await execute(
jlink,
[
...jvmArguments.map((argument) => `-J${argument}`),
"--add-modules",
"java.base",
"--output",
jre,
],
{
timeout: 60_000,
},
);
await execute(join(jre, "bin", javaName), ["-version"], {
timeout: 10_000,
});
await execute(join(jre, "bin/java"), ["-version"], { timeout: 10_000 });
const empty = join(root, "empty-path");
await mkdir(empty);
if (process.platform === "linux") {
@ -74,6 +89,8 @@ export async function verifyAndroidFailureRecovery({
}
const withoutJavaHome = { ...environment };
delete withoutJavaHome.JAVA_HOME;
for (const key of Object.keys(withoutJavaHome))
if (key.toLowerCase() === "path") delete withoutJavaHome[key];
const invalidJar = join(root, "invalid.jar");
await writeFile(invalidJar, "not a Java archive");
const environments = [
@ -85,7 +102,8 @@ export async function verifyAndroidFailureRecovery({
{
label: "missing java",
env: { ...withoutJavaHome, PATH: empty },
observation: "ENOENT",
observation:
process.platform === "win32" ? "Cannot find java.exe" : "ENOENT",
},
{
label: "invalid engine archive with a working JDK",

View File

@ -11,11 +11,16 @@ export const verifyAndroidMetadata = async ({
bridge,
repository,
execute,
jvmArguments = [],
}) => {
const javac =
process.env.JAVA_HOME === undefined
? "javac"
: resolve(process.env.JAVA_HOME, "bin/javac");
: resolve(
process.env.JAVA_HOME,
"bin",
process.platform === "win32" ? "javac.exe" : "javac",
);
try {
await execute(javac, ["-version"], { timeout: 10_000 });
} catch (cause) {
@ -29,6 +34,7 @@ export const verifyAndroidMetadata = async ({
await execute(
javac,
[
...jvmArguments.map((argument) => `-J${argument}`),
"-classpath",
jar,
"-d",
@ -44,6 +50,7 @@ export const verifyAndroidMetadata = async ({
const result = await execute(
java,
[
...jvmArguments,
"-classpath",
`${classes}${delimiter}${jar}`,
"ReaJadxMetadataRegression",

View File

@ -0,0 +1,301 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import {
mkdtemp,
readdir,
realpath,
rmdir,
symlink,
unlink,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
/** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */
export async function verifyWindowsAndroidLifecycle(options) {
assert.equal(process.platform, "win32");
await verifyJavaSelection(options.environment);
const cancellation = await verifyMcp(options, "cancel");
await verifyMcp(options, "disconnect");
await verifyCliOwnerExit(options);
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,
observed_resources: cancellation,
java_home_and_quoted_case_insensitive_path: true,
};
}
async function verifyJavaSelection(environment) {
const selected = await resolveJadxConfiguration(
environment,
"inspect_android_package",
);
const root = await mkdtemp(join(tmpdir(), "rea-windows-java-selection-"));
const alias = join(root, "JDK 路径 with spaces");
const pathEnvironment = Object.fromEntries(
Object.entries(environment).filter(
([key]) => !["path", "java_home"].includes(key.toLowerCase()),
),
);
try {
// The owned junction borrows the selected JDK directory without copying it.
await symlink(dirname(selected.java), alias, "junction");
pathEnvironment.Path = `"${alias}"`;
const onPath = await resolveJadxConfiguration(
pathEnvironment,
"inspect_android_package",
);
assert.equal(onPath.java, await realpath(selected.java));
console.log(
"PASS Windows JAVA_HOME and quoted Unicode/space Path selection",
);
} finally {
await unlink(alias).catch((cause) => {
if (cause.code !== "ENOENT") throw cause;
});
await rmdir(root);
}
}
async function verifyMcp({ entrypoint, environment, path, repository }, mode) {
const baseline = await runtimeRoots();
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
cwd: repository,
env: environment,
stderr: "pipe",
});
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const client = new Client({
name: `rea-windows-android-${mode}`,
version: "1",
});
let observer;
try {
await client.connect(transport);
assert.ok(transport.pid !== null);
observer = observeJava(transport.pid);
const controller = new AbortController();
const pending = client
.callTool(
{ name: "inspect_android_package", arguments: { path } },
{ timeout: 150000, signal: controller.signal },
)
.then(
(response) => ({ response }),
(error) => ({ error }),
);
const resources = await observer.acquired;
if (mode === "cancel") controller.abort();
else await client.close();
const outcome = await deadline(pending, 15000);
assert.ok("error" in outcome, "Cancelled request published a success");
await observer.verifyExit();
await waitFor(
async () => {
const remaining = await runtimeRoots();
return remaining.every((root) => baseline.includes(root));
},
15000,
"Owned Android workspace remained after cleanup",
);
if (mode === "cancel") {
await waitFor(
() => stderr.includes('"status":"error"'),
15000,
`Server did not finish the cancelled request: ${stderr}`,
);
assert.ok(
!stderr.includes('"status":"ok"'),
"Cancelled operation was logged successful",
);
await client.ping();
const next = await client.callTool(
{ name: "inspect_android_package", arguments: { path } },
{ timeout: 150000 },
);
assert.notEqual(next.isError, true, JSON.stringify(next));
assert.ok(next.structuredContent?.evidence_id);
}
console.log(
`PASS Windows MCP ${mode}: real Java exit and workspace cleanup`,
);
return resources;
} finally {
await client.close();
await transport.close();
await observer?.close();
}
}
async function verifyCliOwnerExit({
entrypoint,
environment,
path,
repository,
}) {
const child = spawn(
process.execPath,
[entrypoint, "inspect-android-package", path, "--format", "json"],
{ cwd: repository, env: environment, stdio: ["ignore", "pipe", "pipe"] },
);
let output = "";
child.stdout.on("data", (chunk) => {
output = (output + chunk.toString()).slice(-65536);
});
child.stderr.resume();
const exited = new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
});
assert.ok(child.pid !== undefined);
const observer = observeJava(child.pid);
try {
await observer.acquired;
// Node's Windows SIGTERM terminates the retained child handle abruptly.
// The Java job must settle through kill-on-owner-close without JS cleanup.
assert.equal(child.kill(), true);
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");
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill();
await deadline(exited, 15000);
await observer.close();
}
}
function observeJava(ownerPid) {
assert.ok(Number.isSafeInteger(ownerPid) && ownerPid > 0);
// Retain actual process handles and creation times. The process table only
// discovers the child; no PID or executable path grants termination authority.
const source = `
$ErrorActionPreference='Stop'
$owner=Get-Process -Id ${ownerPid}
$null=$owner.Handle; $ownerStart=$owner.StartTime
$java=$null; $deadline=[DateTime]::UtcNow.AddSeconds(45)
try {
while ([DateTime]::UtcNow -lt $deadline) {
if ($owner.HasExited) { throw 'REA owner exited before Java acquisition' }
$rows=Get-CimInstance Win32_Process -Filter 'ParentProcessId = ${ownerPid}'
foreach ($row in $rows) {
if ($row.Name -eq 'java.exe' -and $row.CommandLine -like '*ReaJadxBridge.java*') {
$candidate=Get-Process -Id $row.ProcessId
$null=$candidate.Handle
$sameCreation=$candidate.StartTime.ToUniversalTime().ToString('yyyyMMddHHmmssffffff') -eq $row.CreationDate.ToUniversalTime().ToString('yyyyMMddHHmmssffffff')
if ($sameCreation -and $candidate.StartTime -ge $ownerStart -and !$candidate.HasExited) {
$java=$candidate
$resourceArgs=@([regex]::Matches($row.CommandLine,'-Xmx[0-9]+m|-XX:ActiveProcessorCount=[0-9]+') | ForEach-Object {$_.Value})
@{affinity=[int64]$java.ProcessorAffinity; priority=[string]$java.PriorityClass; jvm_arguments=$resourceArgs} | ConvertTo-Json -Compress
break
}
$candidate.Dispose()
}
}
if ($null -ne $java) { break }
Start-Sleep -Milliseconds 100
}
if ($null -eq $java) { throw 'No real owned Java engine was acquired' }
$null=[Console]::ReadLine()
if (!$java.WaitForExit(15000)) { throw 'Retained Java process did not exit' }
@{exited=$java.HasExited} | ConvertTo-Json -Compress
} finally { if ($null -ne $java) {$java.Dispose()}; $owner.Dispose() }
`;
const observer = spawn(
join(
process.env.SystemRoot ?? "C:\\Windows",
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
),
[
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
Buffer.from(source, "utf16le").toString("base64"),
],
{ stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
);
let stdout = "";
let stderr = "";
observer.stdout.on("data", (chunk) => {
stdout = (stdout + chunk.toString()).slice(-65536);
});
observer.stderr.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const exited = new Promise((resolve, reject) => {
observer.once("error", reject);
observer.once("close", resolve);
});
const acquired = waitFor(
() => stdout.includes("\n") || observer.exitCode !== null,
50000,
"Java process observer did not acquire a handle",
).then(() => {
assert.ok(stdout.includes("\n"), stderr);
return JSON.parse(stdout.split(/\r?\n/u)[0]);
});
return {
acquired,
async verifyExit() {
observer.stdin.end("verify\n");
assert.equal(await deadline(exited, 20000), 0, stderr);
assert.equal(
JSON.parse(stdout.trim().split(/\r?\n/u).at(-1)).exited,
true,
);
},
async close() {
if (observer.exitCode === null && observer.signalCode === null) {
observer.stdin.end();
observer.kill();
}
await deadline(exited, 20000);
},
};
}
async function runtimeRoots() {
return (await readdir(tmpdir())).filter((name) =>
name.startsWith("rea-android-"),
);
}
async function waitFor(predicate, milliseconds, message) {
const until = Date.now() + milliseconds;
while (Date.now() < until) {
if (await predicate()) return;
await delay(50);
}
throw new Error(message);
}
async function deadline(promise, milliseconds) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error("Windows lifecycle deadline exceeded")),
milliseconds,
);
}),
]).finally(() => clearTimeout(timer));
}

View File

@ -19,9 +19,12 @@ can select Java. REA setup does not install or configure that JAR, and doctor
provider scopes cover Hopper/Ghidra rather than JADX. Use existing tools and
report missing prerequisites for the Android task specifically.
Real verification covers Linux. macOS has the POSIX launch boundary but no
claimed real-provider verification; Windows is unsupported. Calls use one
worker, a bounded JVM heap, and provider deadlines. The APK is never launched,
Linux and macOS use the POSIX owned-process boundary. Windows x64 requires the
matching bundled native controls and uses an owned Job Object with bidirectional
stdio. A full JDK, including `jdk.compiler`, is required on every host; Windows
`JAVA_HOME` selects `bin\java.exe`. Consult the Android guide for actual host
verification coverage. Calls use one worker and provider deadlines; optional
`REA_JADX_HEAP_MIB` selects the JVM heap. The APK is never launched,
and no emulator is required.
Manifest entries and decompiled text are static observations, not execution or

View File

@ -1,6 +1,7 @@
import { constants } from "node:fs";
import { access, realpath, stat } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { delimiter, isAbsolute, join, resolve } from "node:path";
import { systemWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import type { JsonValue } from "../domain/jsonValue.js";
@ -116,7 +117,7 @@ const readJadxConfiguration = async (
): Promise<JadxConfiguration> => {
requireSupportedHost();
const jar = await resolveJar(environment);
const java = resolveJava(environment);
const java = await resolveJava(environment, signal);
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
const jvmArguments = resolveJvmArguments(environment);
await inspectJavaRuntime(java, environment, signal);
@ -169,9 +170,26 @@ const requireEngineArchive = async (
const requireSupportedHost = (): void => {
if (process.platform === "linux" || process.platform === "darwin") return;
if (process.platform === "win32" && process.arch === "x64") {
const loaded = systemWindowsNativeAuthority();
if (loaded.available && loaded.authority.inspection.protocolStdin === true)
return;
throw configurationFailure(
"runtime_missing",
loaded.available
? "The bundled Windows native artifact lacks owned protocol stdin; use a matching current Windows x64 REA package."
: loaded.reason,
{
platform: process.platform,
architecture: process.arch,
phase: "process-ownership",
owned_protocol_stdin: false,
},
);
}
throw configurationFailure(
"unsupported_host",
`Android JADX subprocess ownership is unsupported on ${process.platform}; use Linux or macOS. The metadata bridge has real-provider verification on macOS arm64.`,
`Android JADX subprocess ownership is unsupported on ${process.platform}/${process.arch}; use Linux, macOS, or Windows x64 with its bundled native controls.`,
{ platform: process.platform },
);
};
@ -206,12 +224,52 @@ const resolveJar = async (
}
};
const resolveJava = (
const resolveJava = async (
environment: Readonly<Record<string, string | undefined>>,
): string => {
if (environment.JAVA_HOME === undefined) return "java";
signal?: AbortSignal,
): Promise<string> => {
const executable = process.platform === "win32" ? "java.exe" : "java";
if (environment.JAVA_HOME === undefined) {
if (process.platform !== "win32") return executable;
const path =
Object.entries(environment).find(
([key]) => key.toLowerCase() === "path",
)?.[1] ?? "";
for (const directory of [process.cwd(), ...path.split(delimiter)]) {
signal?.throwIfAborted();
// Windows PATH commonly quotes individual entries containing spaces.
const selected =
directory.startsWith('"') && directory.endsWith('"')
? directory.slice(1, -1)
: directory;
const candidate = resolve(selected, executable);
try {
await access(candidate, constants.R_OK | constants.X_OK);
if ((await stat(candidate)).isFile()) return await realpath(candidate);
} catch (cause: unknown) {
if (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
)
continue;
throw configurationFailure(
"runtime_missing",
`Cannot inspect selected Java executable ${candidate}: ${errorText(cause)}`,
{ java_executable: candidate },
cause,
);
}
}
throw configurationFailure(
"runtime_missing",
"Cannot find java.exe on the selected Windows PATH. Select a full JDK with JAVA_HOME or PATH, then verify it with java --list-modules. REA does not install Java.",
{ java_executable: executable },
);
}
if (isAbsolute(environment.JAVA_HOME))
return join(environment.JAVA_HOME, "bin", "java");
return join(environment.JAVA_HOME, "bin", executable);
throw configurationFailure(
"open_options_invalid",
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",

View File

@ -5,6 +5,7 @@ import {
type Transport,
} from "@modelcontextprotocol/client";
import { randomUUID } from "node:crypto";
import { finished } from "node:stream/promises";
import type {
OwnedProviderProcessSpawnOptions,
SpawnedOwnedProviderProcess,
@ -19,7 +20,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
const MAX_OPERATION_BYTES = 32 * 1024 * 1024;
/** Injectable process acquisition seam; the production launcher owns a POSIX group. */
/** Injectable acquisition seam; production owns a POSIX group or Windows job. */
export type JadxLauncher = (
options: OwnedProviderProcessSpawnOptions,
) => Promise<SpawnedOwnedProviderProcess>;
@ -139,8 +140,19 @@ export class JadxMcpTransport implements Transport {
process?.stderr?.off("data", this.#countStderr);
process?.off("error", this.#error);
process?.off("exit", this.#exit);
process?.stdin?.off("error", this.#error);
const input = process?.stdin;
// A job termination can complete an in-flight pipe write asynchronously.
// Observe its errors until the owned Writable has finished closing.
const inputClosed =
input === undefined || input === null
? undefined
: finished(input, { readable: false, cleanup: true }).catch(
() => undefined,
);
const stopped = await this.#supervisor?.stop();
input?.destroy();
await inputClosed;
input?.off("error", this.#error);
this.onclose?.();
if (stopped?.status === "incomplete")
throw new ProviderCleanupError("jadx", [this.runId], {

View File

@ -352,7 +352,7 @@ const workflowAvailabilityFor = ({
: "provider_missing",
remediation:
policy.androidAnalysisAvailability?.reason ??
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux or macOS.",
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
};
const browser = browserProviderAvailability(name, kind, policy);
if (browser !== null) return browser;

View File

@ -151,16 +151,13 @@ export const spawnOwnedProviderProcess = async (
REA_PROCESS_RUN_ID: options.runId,
};
if (platform === "win32" && process.platform === "win32") {
if (options.stdin === "pipe")
throw new Error(
"Owned Windows provider processes do not support protocol stdin",
);
const child = new WindowsOwnedProcess(
options.command,
options.arguments,
options.cwd,
environment,
options.windowsVerbatimArguments ?? false,
options.stdin === "pipe",
);
return {
process: child,

View File

@ -311,6 +311,6 @@ const missingAndroidAvailability = async (): Promise<ProviderAvailability> => ({
status: "unavailable",
code: "not_configured",
reason:
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux or macOS.",
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
diagnostics: { configured: false },
});

View File

@ -7,7 +7,7 @@ import {
type WindowsNativeLoadHost,
} from "./WindowsNativeLoader.js";
const fixture = () => {
const fixture = (protocolStdin?: boolean) => {
const bytes = Buffer.alloc(128);
bytes.write("MZ");
bytes.writeUInt32LE(64, 0x3c);
@ -51,6 +51,7 @@ const fixture = () => {
privateDacl: true,
atomicJobAssignment: true,
killOnOwnerClose: true,
...(protocolStdin === undefined ? {} : { protocolStdin }),
}),
};
},
@ -59,6 +60,22 @@ const fixture = () => {
};
describe("packaged Windows native admission", () => {
it.each([true, false])(
"preserves protocol stdin capability %s alongside existing job controls",
(protocolStdin) => {
const value = fixture(protocolStdin);
expect(loadWindowsNativeAuthority(value.host)).toMatchObject({
available: true,
authority: {
inspection: {
protocolStdin,
atomicJobAssignment: true,
killOnOwnerClose: true,
},
},
});
},
);
it("loads only after compatibility and digest checks, preserving producer identity", () => {
const value = fixture();
expect(loadWindowsNativeAuthority(value.host)).toMatchObject({

View File

@ -43,6 +43,7 @@ const inspectionSchema = z.strictObject({
privateDacl: z.literal(true),
atomicJobAssignment: z.literal(true),
killOnOwnerClose: z.literal(true),
protocolStdin: z.boolean().optional(),
});
type NativeCall = (

View File

@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { PassThrough, Writable } from "node:stream";
import { z } from "zod";
@ -36,6 +36,7 @@ export const quoteWindowsProcessArgument = (value: string): string => {
export class WindowsOwnedProcess extends EventEmitter {
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();
readonly stdin: Writable | null;
readonly signalCode: NodeJS.Signals | null = null;
exitCode: number | null = null;
readonly pid: number;
@ -55,9 +56,14 @@ export class WindowsOwnedProcess extends EventEmitter {
cwd: string | undefined,
environment: NodeJS.ProcessEnv,
verbatim: boolean,
protocolStdin = false,
) {
super();
this.#authority = requireWindowsNativeAuthority();
if (protocolStdin && this.#authority.inspection.protocolStdin !== true)
throw new Error(
"The bundled Windows native artifact does not support owned protocol stdin; use a matching current Windows x64 REA package.",
);
const entries = Object.entries(environment)
.filter((entry): entry is [string, string] => entry[1] !== undefined)
.map(([key, value]) => `${key}=${value}`);
@ -75,16 +81,70 @@ export class WindowsOwnedProcess extends EventEmitter {
line,
cwd ?? "",
entries,
...(protocolStdin ? [true] : []),
]),
);
this.pid = launched.pid;
this.#handle = launched.handle;
this.stdin = protocolStdin
? new Writable({
write: (chunk: unknown, _encoding, callback) => {
void this.#writeInput(chunk).then(
() => callback(),
(cause: unknown) =>
callback(
cause instanceof Error ? cause : new Error(String(cause)),
),
);
},
final: (callback) => {
try {
this.#authority.call("process_stdin_close", [this.#handle]);
callback();
} catch (cause: unknown) {
callback(
cause instanceof Error ? cause : new Error(String(cause)),
);
}
},
destroy: (cause, callback) => {
try {
if (!this.#closed)
this.#authority.call("process_stdin_close", [this.#handle]);
callback(cause);
} catch (failure: unknown) {
callback(
failure instanceof Error ? failure : new Error(String(failure)),
);
}
},
})
: null;
this.#settled = new Promise((resolve) => {
this.#settle = resolve;
});
this.#schedule();
}
async #writeInput(chunk: unknown): Promise<void> {
const bytes = z.instanceof(Buffer).parse(chunk);
const written = z
.number()
.int()
.nonnegative()
.max(Number.MAX_SAFE_INTEGER)
.parse(
await this.#authority.call("process_stdin_write", [
this.#handle,
bytes,
]),
);
if (written !== bytes.length)
throw new Error(
"Owned Windows input write did not preserve all requested bytes",
);
}
/** Terminate the retained job, never a PID supplied by another caller. */
kill(signal?: NodeJS.Signals | number): boolean {
if (signal === 0)
@ -184,6 +244,7 @@ export class WindowsOwnedProcess extends EventEmitter {
if (this.#timer !== undefined) clearTimeout(this.#timer);
this.#authority.call("process_close", [this.#handle]);
this.#closed = true;
this.stdin?.destroy();
this.stdout.end();
this.stderr.end();
this.#settle?.();

View File

@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { PassThrough, Writable } from "node:stream";
import {
serializeMessage,
type JSONRPCMessage,
@ -14,7 +14,9 @@ import {
class FixtureProcess extends EventEmitter implements ProviderProcessHandle {
readonly pid = 1;
readonly stdout = new PassThrough();
readonly stdin = new PassThrough();
constructor(readonly stdin: Writable = new PassThrough()) {
super();
}
readonly stderr = new PassThrough();
readonly exitCode = null;
readonly signalCode = null;
@ -24,6 +26,48 @@ class FixtureProcess extends EventEmitter implements ProviderProcessHandle {
}
}
it("joins pending input errors before detaching the owned protocol stream", async () => {
let completeWrite: ((cause: Error) => void) | undefined;
const failure = new Error("owned pipe closed during cancellation");
const process = new FixtureProcess(
new Writable({
write: (_chunk, _encoding, callback) => {
completeWrite = callback;
},
}),
);
const transport = new JadxMcpTransport(
{ command: "fixture", arguments: [] },
async ({ runId }) => ({
process,
ownership: { runId, leaderPid: 1, processGroupId: 1 },
cleanup: async () => {
completeWrite?.(failure);
process.emit("exit", 1, null);
process.emit("close", 1, null);
process.stdout.end();
process.stderr.end();
return { cleaned: true, signaled: true };
},
}),
);
const errors: Error[] = [];
transport.onerror = (cause) => errors.push(cause);
await transport.start();
const pending = transport
.send({ jsonrpc: "2.0", id: 1, method: "probe" })
.then(
() => undefined,
(cause: unknown) => cause,
);
expect(completeWrite).toBeTypeOf("function");
await transport.close();
expect(await pending).toBe(failure);
expect(errors).toEqual([failure]);
expect(process.stdin.closed).toBe(true);
await transport.close();
});
it("decodes split UTF-8 and newline bytes, then routes multiple complete frames", async () => {
const process = new FixtureProcess();
const launcher: JadxLauncher = async ({ runId }) => ({