diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 0a07217540..b1f2578f7b 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -1,3 +1,4 @@ +import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import type { AnalysisSettingsReader } from "../domain/analysisSettings.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; @@ -295,7 +296,8 @@ export class JadxProvider implements AndroidAnalysisPort { /** Report actual local prerequisites without starting JADX or opening a target. */ inspectAvailability(signal?: AbortSignal) { - const pending = inspectJadxAvailability(this.environment, { + const environment = snapshotEnvironment(this.environment); + const pending = inspectJadxAvailability(environment, { operationTimeoutMs: this.settings?.().overrides["jadx.operation_timeout_ms"], resourceOwner: this.#prerequisiteOwner, @@ -442,10 +444,11 @@ export class JadxProvider implements AndroidAnalysisPort { request: AndroidRequest, options?: ExecutionOptions, ): Promise { + const environment = snapshotEnvironment(this.environment); // Resolve the accepted operation's override before acquiring any resource. // An unusable launch value is refused without starting the engine. const timeoutMs = resolveJadxOperationTimeout( - this.environment, + environment, request.operation, this.settings?.().overrides["jadx.operation_timeout_ms"], ); @@ -471,7 +474,7 @@ export class JadxProvider implements AndroidAnalysisPort { `Target ${target.path} is ${target.format}; provide one standalone APK.`, ); const configuration = await resolveJadxConfigurationInputs( - this.environment, + environment, request.operation, signal, ); @@ -513,11 +516,11 @@ export class JadxProvider implements AndroidAnalysisPort { java: configuration.java, arguments: configuration.jvmArguments, options: [ - this.environment.JAVA_TOOL_OPTIONS, - this.environment._JAVA_OPTIONS, - this.environment.JDK_JAVA_OPTIONS, - this.environment.PATH, - this.environment.JAVA_HOME, + environment.JAVA_TOOL_OPTIONS, + environment._JAVA_OPTIONS, + environment.JDK_JAVA_OPTIONS, + environment.PATH, + environment.JAVA_HOME, ], }); if (this.#retained !== undefined && this.#retained.key !== key) { @@ -528,7 +531,7 @@ export class JadxProvider implements AndroidAnalysisPort { if (retained === undefined) { await validateJadxConfiguration( configuration, - this.environment, + environment, request.operation, { resourceOwner: this.#prerequisiteOwner, @@ -589,7 +592,7 @@ export class JadxProvider implements AndroidAnalysisPort { bridge, ], cwd: root.path, - hostEnvironment: this.environment, + hostEnvironment: environment, signal: workspaceSignal, }, this.launcher, diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index 39b2b1782d..6fbc7cff96 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -1,3 +1,4 @@ +import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js"; import { join } from "node:path"; import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js"; @@ -327,6 +328,8 @@ export class FirmwareProvider implements FirmwareAnalysisPort { request: FirmwareRequest, options?: ExecutionOptions, ): Promise { + // Capture configuration after queue admission; later operations may select updates. + const environment = snapshotEnvironment(this.environment); let root: PrivateRuntimeRoot | undefined; let rootLease: AnalysisCleanupLease | undefined; const engineName = @@ -358,7 +361,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort { ); }); const command = await resolveFirmwareCommand( - this.environment, + environment, engineName, request.operation, this.#files, @@ -385,7 +388,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort { limiter: command.limiter.invocationPath, args, cwd: root?.path ?? "", - environment: this.environment, + environment, signal, deadline, retainCleanup: (supervisor) => { diff --git a/src/javascript/recovery/WakaruProvider.ts b/src/javascript/recovery/WakaruProvider.ts index bd050349e0..adac2b6f53 100644 --- a/src/javascript/recovery/WakaruProvider.ts +++ b/src/javascript/recovery/WakaruProvider.ts @@ -1,3 +1,4 @@ +import { snapshotEnvironment } from "../../process/snapshotEnvironment.js"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -109,6 +110,8 @@ export class WakaruProvider implements JavaScriptRecoveryPort { ): Promise> { const pending = await this.#retire(); if (pending !== undefined) return err(pending); + // Capture configuration after queue/cleanup admission; later requests may select updates. + const environment = snapshotEnvironment(this.environment); let root: string | undefined; let tree: SafeOutputTree | undefined; let failed: { readonly cause: unknown } | undefined; @@ -117,7 +120,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort { try { checkRecoveryCancellation(options?.signal); const engine = await resolveWakaruCommand( - this.environment, + environment, this.#resources, options?.signal, ); @@ -149,7 +152,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort { input, sources, engine, - environment: this.environment, + environment, launcher: this.launcher, options, deadline, diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 9c6b61ff92..420d902b98 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -45,6 +45,7 @@ import { } from "../domain/wasm/wasmArtifact.js"; import { err, ok, type Result } from "../domain/result.js"; import { runOwnedCommand } from "../process/OwnedCommand.js"; +import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import { artifactCommandOutput } from "../process/ArtifactAnalysisResources.js"; import { @@ -105,8 +106,11 @@ export class WabtArtifactProvider implements WasmArtifactPort { input: InspectWasmArtifactInput, options?: WasmArtifactExecutionOptions, ): Promise> { + const environment = snapshotEnvironment(this.environment); try { - return await this.#resources.run(() => this.#inspect(input, options)); + return await this.#resources.run(() => + this.#inspect(input, environment, options), + ); } catch (cause: unknown) { if (cause instanceof ArtifactResourceRetryFailure) return err(resourceCleanupFailure(cause)); @@ -130,6 +134,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { async #inspect( input: InspectWasmArtifactInput, + environment: Readonly, options?: WasmArtifactExecutionOptions, ): Promise> { let runtime: WabtRuntime | undefined; @@ -208,7 +213,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { throw new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, }); - const directory = this.environment.REA_WABT_BIN_DIRECTORY ?? ""; + const directory = environment.REA_WABT_BIN_DIRECTORY ?? ""; if (!isAbsolute(directory)) throw new Error( "REA_WABT_BIN_DIRECTORY must be an absolute tool directory.", @@ -296,7 +301,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { // and process-group identity own cleanup after their exec handoff. expectedCommand: null, runId: `rea-wabt-${randomUUID()}`, - hostEnvironment: this.environment, + hostEnvironment: environment, }, { timeoutMs: commandTimeoutMs, diff --git a/tests/boundary/filesystem/wabtEnvironment.test.ts b/tests/boundary/filesystem/wabtEnvironment.test.ts new file mode 100644 index 0000000000..e6b1d9a90a --- /dev/null +++ b/tests/boundary/filesystem/wabtEnvironment.test.ts @@ -0,0 +1,87 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; +import { WabtArtifactProvider } from "../../../src/wasm/WabtArtifactProvider.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// Real subprocesses with WABT-shaped replies establish environment ownership, +// not the real WABT release's interpretation of these bytes. +it.skipIf(process.platform === "win32")( + "keeps one WABT inspection coherent while accepting environment changes on the next call", + async () => { + const directory = await createTestTempDirectory("rea-wabt-environment-"); + const path = join(directory, "selected.wasm"); + const log = join(directory, "children.jsonl"); + await writeFile(path, Buffer.alloc(76, 1)); + const dump = await readFile( + new URL("../../fixtures/wasm/objdump.txt", import.meta.url), + "utf8", + ); + for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) { + const output = + tool === "wasm-objdump" + ? dump + : tool === "wasm2wat" + ? '(module (@custom "test" "payload"))\n' + : ""; + await writeFile( + join(directory, tool), + `#!${process.execPath} +const fs = require("node:fs"); +fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ + marker: process.env.REA_TEST_MARKER, + empty: process.env.REA_TEST_EMPTY, + absent: Object.hasOwn(process.env, "REA_TEST_ABSENT") +}) + "\\n"); +process.stdout.write(process.argv[2] === "--version" ? "1.0.42\\n" : ${JSON.stringify(output)}); +`, + { mode: 0o700 }, + ); + } + const environment: NodeJS.ProcessEnv = { + REA_WABT_BIN_DIRECTORY: directory, + REA_TEST_MARKER: "selected-environment", + REA_TEST_EMPTY: "", + }; + let runtimeCreations = 0; + const provider = new WabtArtifactProvider(environment, undefined, () => { + runtimeCreations += 1; + // Selection already succeeded, but version probes have not launched. + if (runtimeCreations === 2) { + environment.REA_WABT_BIN_DIRECTORY = join(directory, "unselected"); + environment.REA_TEST_MARKER = "changed-environment"; + environment.REA_TEST_EMPTY = "changed"; + environment.REA_TEST_ABSENT = "added"; + } + return PrivateRuntimeRoot.create({ prefix: "rea-wabt-environment-" }); + }); + onTestFinished(() => provider.close()); + const input = { path, glue_paths: [], candidate_paths: [] }; + const first = await provider.inspect(input); + if (!first.ok) throw first.error; + + const second = await provider.inspect(input); + if (!second.ok) throw second.error; + expect(second.value.result).toEqual(first.value.result); + const children = (await readFile(log, "utf8")) + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line) as unknown); + expect(children).toEqual( + Array.from({ length: 12 }, () => ({ + marker: "selected-environment", + empty: "", + absent: false, + })), + ); + expect(JSON.stringify(second.value)).not.toContain("selected-environment"); + // Later calls intentionally observe reconfiguration, not a lifetime freeze. + const third = await provider.inspect(input); + expect(third).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + expect(runtimeCreations).toBe(2); + }, +); diff --git a/tests/boundary/process/artifactSelectedCommand.test.ts b/tests/boundary/process/artifactSelectedCommand.test.ts index c2571a00c0..429997bf08 100644 --- a/tests/boundary/process/artifactSelectedCommand.test.ts +++ b/tests/boundary/process/artifactSelectedCommand.test.ts @@ -153,103 +153,132 @@ const selectedEngineLauncher = (kind: "firmware" | "wakaru"): string => { return `#!/bin/sh\nIFS= read -r role < "$0.role" || exit 19\nif [ -f "$0.retarget" ]; then\n IFS= read -r target < "$0.retarget"\n /bin/ln -sf "$target" "$0"\nfi\nif [ "$role" = limiter ]; then\n while [ "$1" != -- ]; do shift; done\n shift\n exec "$@"\nfi\nexec ${invocation} "$@"\n`; }; -// Exercise shared CLI/MCP workflows with real children and filesystem. Only -// Linux/x64 admission and /proc limits are simulated; the launcher restores the -// actual host before entering real process supervision. +// Own real wrapper processes and their workspaces. Linux/x64 admission and +// /proc limits are simulated; supervision always runs on the actual host. +const createSelectedEngineFixture = async ( + kind: "firmware" | "wakaru", + afterLaunch?: () => void, +) => { + const directory = await createTestTempDirectory("rea-selected-command-"); + const bin = join(directory, "bin"), + libexec = join(directory, "libexec"); + await mkdir(bin); + await mkdir(libexec); + const command = join(bin, "engine"), + limiter = join(bin, "prlimit"); + const commandSource = join(libexec, "engine"), + limiterSource = join(libexec, "limiter"); + const replacement = join(libexec, "replacement"); + const script = selectedEngineLauncher(kind); + for (const path of [commandSource, limiterSource, replacement]) + await writeFile(path, script, { mode: 0o755 }); + await symlink(commandSource, command); + await symlink(limiterSource, limiter); + await writeFile(`${command}.role`, `${kind}\n`); + await writeFile(`${limiter}.role`, "limiter\n"); + const input = join(directory, kind === "firmware" ? "input.bin" : "input.js"); + await writeFile( + input, + kind === "firmware" ? "fixture-input-data" : "module.exports = 42;", + ); + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + const arch = Object.getOwnPropertyDescriptor(process, "arch"); + if (platform === undefined || arch === undefined) + throw new Error("Missing host descriptors"); + const restoreHost = () => { + Object.defineProperty(process, "platform", platform); + Object.defineProperty(process, "arch", arch); + }; + const launches: { + cwd?: string; + pid?: number; + command: string; + environmentMarker: string | undefined; + }[] = []; + const environment = { + ...process.env, + REA_BINWALK_COMMAND: command, + REA_FIRMWARE_PRLIMIT_COMMAND: limiter, + REA_WAKARU_COMMAND: command, + REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter, + REA_SELECTED_ENVIRONMENT: "initial", + }; + const launcher: typeof spawnOwnedProviderProcess = async (options) => { + restoreHost(); + const child = await spawnOwnedProviderProcess(options); + launches.push({ + command: options.command, + environmentMarker: options.hostEnvironment?.REA_SELECTED_ENVIRONMENT, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + ...(child.process.pid === undefined ? {} : { pid: child.process.pid }), + }); + afterLaunch?.(); + return child; + }; + const fileSizeCeiling = vi + .spyOn(FirmwareFileAccess.prototype, "fileSizeCeiling") + .mockResolvedValue(Number.MAX_SAFE_INTEGER); + onTestFinished(() => fileSizeCeiling.mockRestore()); + const service = + kind === "firmware" + ? new FirmwareAnalysisService(new FirmwareProvider(environment, launcher)) + : new JavaScriptRecoveryService( + new WakaruProvider(environment, launcher), + ); + onTestFinished(async () => { + restoreHost(); + await service.close(); + for (const launch of launches) { + if (launch.pid !== undefined) + expect(await observeProcessStartIdentity(launch.pid)).toBeUndefined(); + if (launch.cwd !== undefined) + await expect(readdir(launch.cwd)).rejects.toMatchObject({ + code: "ENOENT", + }); + } + }); + let sequence = 0; + const execute = async () => { + Object.defineProperty(process, "platform", { + ...platform, + value: "linux", + }); + Object.defineProperty(process, "arch", { ...arch, value: "x64" }); + try { + return service instanceof FirmwareAnalysisService + ? await service.execute("inspect_firmware_regions", { path: input }) + : await service.recover({ + path: input, + output_directory: join(directory, `output-${sequence++}`), + }); + } finally { + restoreHost(); + } + }; + return { + bin, + command, + limiter, + commandSource, + limiterSource, + replacement, + environment, + launches, + execute, + }; +}; + it.skipIf(process.platform === "win32").each(["firmware", "wakaru"] as const)( "executes selected %s wrappers and retains completed output when either alias changes", async (kind) => { - const directory = await createTestTempDirectory("rea-selected-command-"); - const bin = join(directory, "bin"), - libexec = join(directory, "libexec"); - await mkdir(bin); - await mkdir(libexec); - const command = join(bin, "engine"), - limiter = join(bin, "prlimit"); - const commandSource = join(libexec, "engine"), - limiterSource = join(libexec, "limiter"); - const replacement = join(libexec, "replacement"); - const script = selectedEngineLauncher(kind); - for (const path of [commandSource, limiterSource, replacement]) - await writeFile(path, script, { mode: 0o755 }); - await symlink(commandSource, command); - await symlink(limiterSource, limiter); - await writeFile(`${command}.role`, `${kind}\n`); - await writeFile(`${limiter}.role`, "limiter\n"); - const input = join( - directory, - kind === "firmware" ? "input.bin" : "input.js", - ); - await writeFile( - input, - kind === "firmware" ? "fixture-input-data" : "module.exports = 42;", - ); - const platform = Object.getOwnPropertyDescriptor(process, "platform"); - const arch = Object.getOwnPropertyDescriptor(process, "arch"); - if (platform === undefined || arch === undefined) - throw new Error("Missing host descriptors"); - const restoreHost = () => { - Object.defineProperty(process, "platform", platform); - Object.defineProperty(process, "arch", arch); - }; - const launches: { cwd?: string; pid?: number }[] = []; - const launcher: typeof spawnOwnedProviderProcess = async (options) => { - restoreHost(); - const child = await spawnOwnedProviderProcess(options); - launches.push({ - ...(options.cwd === undefined ? {} : { cwd: options.cwd }), - ...(child.process.pid === undefined ? {} : { pid: child.process.pid }), - }); - return child; - }; - const fileSizeCeiling = vi - .spyOn(FirmwareFileAccess.prototype, "fileSizeCeiling") - .mockResolvedValue(Number.MAX_SAFE_INTEGER); - onTestFinished(() => fileSizeCeiling.mockRestore()); - const service = - kind === "firmware" - ? new FirmwareAnalysisService( - new FirmwareProvider( - { - ...process.env, - REA_BINWALK_COMMAND: command, - REA_FIRMWARE_PRLIMIT_COMMAND: limiter, - }, - launcher, - ), - ) - : new JavaScriptRecoveryService( - new WakaruProvider( - { - ...process.env, - REA_WAKARU_COMMAND: command, - REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter, - }, - launcher, - ), - ); - onTestFinished(async () => { - restoreHost(); - await service.close(); - }); - let sequence = 0; - const execute = async () => { - Object.defineProperty(process, "platform", { - ...platform, - value: "linux", - }); - Object.defineProperty(process, "arch", { ...arch, value: "x64" }); - try { - return service instanceof FirmwareAnalysisService - ? await service.execute("inspect_firmware_regions", { path: input }) - : await service.recover({ - path: input, - output_directory: join(directory, `output-${sequence++}`), - }); - } finally { - restoreHost(); - } - }; + const { + command, + limiter, + commandSource, + limiterSource, + replacement, + execute, + } = await createSelectedEngineFixture(kind); const first = await execute(); if (!first.ok) throw first.error; expect(first.value.normalized_result).toMatchObject({ @@ -287,14 +316,127 @@ it.skipIf(process.platform === "win32").each(["firmware", "wakaru"] as const)( selectedPath, ); } - for (const launch of launches) { - if (launch.pid !== undefined) - expect(await observeProcessStartIdentity(launch.pid)).toBeUndefined(); - if (launch.cwd !== undefined) - await expect(readdir(launch.cwd)).rejects.toMatchObject({ - code: "ENOENT", - }); - } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps firmware configuration consistent within an operation and accepts later updates", + async () => { + const { + bin, + command, + limiter, + commandSource, + limiterSource, + environment, + launches, + execute, + } = await createSelectedEngineFixture("firmware"); + const nextCommand = join(bin, "next-engine"); + const nextLimiter = join(bin, "next-prlimit"); + await symlink(commandSource, nextCommand); + await symlink(limiterSource, nextLimiter); + await writeFile(`${nextCommand}.role`, "firmware\n"); + await writeFile(`${nextLimiter}.role`, "limiter\n"); + const originalHash = FirmwareFileAccess.prototype.hash; + const hash = vi.spyOn(FirmwareFileAccess.prototype, "hash"); + onTestFinished(() => hash.mockRestore()); + hash.mockImplementationOnce(async function ( + this: FirmwareFileAccess, + path, + signal, + ) { + const digest = await originalHash.call(this, path, signal); + environment.REA_BINWALK_COMMAND = nextCommand; + environment.REA_FIRMWARE_PRLIMIT_COMMAND = nextLimiter; + environment.REA_SELECTED_ENVIRONMENT = "updated"; + return digest; + }); + const first = await execute(); + if (!first.ok) throw first.error; + expect(first.value.raw_result).toMatchObject({ + version_execution: { command, resource_limiter: limiter }, + }); + expect(launches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: limiter, + environmentMarker: "initial", + }), + ]), + ); + expect( + launches.every((launch) => launch.environmentMarker === "initial"), + ).toBe(true); + const next = await execute(); + if (!next.ok) throw next.error; + expect(next.value.raw_result).toMatchObject({ + version_execution: { + command: nextCommand, + resource_limiter: nextLimiter, + }, + }); + expect(launches.at(-1)).toMatchObject({ + command: nextLimiter, + environmentMarker: "updated", + }); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps Wakaru configuration consistent across version and recovery launches", + async () => { + let mutateOnce = true; + const fixture = await createSelectedEngineFixture("wakaru", () => { + if (!mutateOnce) return; + mutateOnce = false; + fixture.environment.REA_WAKARU_COMMAND = nextCommand; + fixture.environment.REA_JAVASCRIPT_PRLIMIT_COMMAND = nextLimiter; + fixture.environment.REA_SELECTED_ENVIRONMENT = "updated"; + }); + const { + bin, + command, + limiter, + commandSource, + limiterSource, + launches, + execute, + } = fixture; + const nextCommand = join(bin, "next-engine"); + const nextLimiter = join(bin, "next-prlimit"); + await symlink(commandSource, nextCommand); + await symlink(limiterSource, nextLimiter); + await writeFile(`${nextCommand}.role`, "wakaru\n"); + await writeFile(`${nextLimiter}.role`, "limiter\n"); + const first = await execute(); + if (!first.ok) throw first.error; + expect(first.value.raw_result).toMatchObject({ + version: { command, resource_limiter: limiter }, + }); + expect( + launches.map(({ command, environmentMarker }) => ({ + command, + environmentMarker, + })), + ).toEqual([ + { command: limiter, environmentMarker: "initial" }, + { command: limiter, environmentMarker: "initial" }, + ]); + const next = await execute(); + if (!next.ok) throw next.error; + expect(next.value.raw_result).toMatchObject({ + version: { command: nextCommand, resource_limiter: nextLimiter }, + }); + expect( + launches.slice(2).map(({ command, environmentMarker }) => ({ + command, + environmentMarker, + })), + ).toEqual([ + { command: nextLimiter, environmentMarker: "updated" }, + { command: nextLimiter, environmentMarker: "updated" }, + ]); }, ); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 0fffb195bc..db13a0fbcf 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -459,6 +459,38 @@ it("retire sessions when APK bytes, engine bytes or JVM options change", async ( await verifyCleanup(launches); }); +it("keeps one operation's JVM environment aligned with its retained session identity", async () => { + const { service, apk, launches, provider, environment } = await setup(); + environment.JAVA_TOOL_OPTIONS = "-Xmx512m"; + const createRoot = PrivateRuntimeRoot.create.bind(PrivateRuntimeRoot); + const allocation = vi + .spyOn(PrivateRuntimeRoot, "create") + .mockImplementationOnce(async (options) => { + // Configuration and the reuse key are already admitted; launch follows + // this asynchronous workspace boundary. + environment.JAVA_TOOL_OPTIONS = "-Xmx8g"; + return createRoot(options); + }); + try { + const first = await service.execute("inspect_android_package", { + path: apk, + }); + if (!first.ok) throw first.error; + expect(allocation).toHaveBeenCalledOnce(); + environment.JAVA_TOOL_OPTIONS = "-Xmx512m"; + const second = await service.execute("inspect_android_package", { + path: apk, + }); + if (!second.ok) throw second.error; + expect(launches).toHaveLength(1); + expect(launches[0]?.javaToolOptions).toBe("-Xmx512m"); + } finally { + allocation.mockRestore(); + await provider.close(); + await verifyCleanup(launches); + } +}); + it("retires an idle engine and starts a fresh one for a later request", async () => { const { service, apk, launches, provider } = await setup(); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });