302 lines
9.9 KiB
JavaScript
302 lines
9.9 KiB
JavaScript
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));
|
|
}
|