From 33d5ae0b52b8684634ecf14d4e9469968c69d462 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:14:25 +0800 Subject: [PATCH] fix(hopper): reserve Linux demo application before launch (#1158) * test(hopper): give locals oracle explicit stack storage * fix(hopper): reserve Linux demo application before launch * fix(hopper): serialize stale lease recovery --- docs/installation.md | 13 ++ docs/testing.md | 7 +- scripts/lib/real-hopper-boundaries.mjs | 12 ++ scripts/lib/real-hopper-session-isolation.mjs | 53 +++++ src/hopper/BridgeLauncher.ts | 50 +++-- src/hopper/HopperTargetLease.test.ts | 150 ++++++++++++- src/hopper/HopperTargetLease.ts | 199 +++++++++++++----- .../bridgeTargetLease.integration.test.ts | 149 ++++++++++--- tests/conformance/c/fixture.c | 4 +- 9 files changed, 532 insertions(+), 105 deletions(-) create mode 100644 scripts/lib/real-hopper-session-isolation.mjs diff --git a/docs/installation.md b/docs/installation.md index b8bff07d3..17c9be092 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -310,6 +310,19 @@ host mount and the rest of `/tmp` remain unchanged; this fallback never invokes `sudo`. `rea doctor --provider hopper --json` reports the selected strategy and both host and effective mount facts. +The supported Linux demo forwards additional launches into its existing +application, even across private displays. REA therefore reserves one Linux +Hopper application per user across cooperating REA processes. A competing +CLI or MCP session receives the owning session ID before Hopper is launched, +for both the same target and a different target. Switch targets through that +MCP session, or close it before starting another session. Closing the owner +releases the reservation. This does not attach to or claim ownership of +manually opened Hopper applications. + +Stale lease recovery is serialized. If an interrupted recovery leaves its +reservation directory, the diagnostic identifies that exact path. Stop REA +sessions before removing that recovery directory and retrying. + ### Launcher paths and troubleshooting On macOS, REA uses diff --git a/docs/testing.md b/docs/testing.md index 7adcf3123..4f4633bcf 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -230,7 +230,12 @@ subsequent wire recovery and clean shutdown. Caller timeout does not interrupt Hopper's synchronous native operation. Socket boundary tests deterministically cover active and queued expiry, late replies and timer cleanup. -The Linux demo lane remains a separate `verify:hopper:linux` command. +The Linux demo lane remains a separate `verify:hopper:linux` command. Its +lifecycle checks attempt competing CLI launches for the active target and a +different target, requiring rejection before either can change the owning +MCP session's documents or procedures. Lease boundary tests also preserve +unresponsive or malformed live endpoints while allowing confirmed stale +sockets to recover. `verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and diff --git a/scripts/lib/real-hopper-boundaries.mjs b/scripts/lib/real-hopper-boundaries.mjs index b094cf412..6e6a6dfe9 100644 --- a/scripts/lib/real-hopper-boundaries.mjs +++ b/scripts/lib/real-hopper-boundaries.mjs @@ -8,6 +8,7 @@ import Ajv from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs"; import { verifyHopperCliSelectors } from "./real-hopper-cli-selectors.mjs"; +import { verifyLinuxHopperLaunchIsolation } from "./real-hopper-session-isolation.mjs"; import { verifyHopperNavigationAndText } from "./real-hopper-navigation.mjs"; import { verifyHopperWorkflows } from "./real-hopper-workflows.mjs"; import { @@ -327,6 +328,16 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) { procedure: entry.value, }); const firstDocument = await call("current_document"); + const linuxApplicationIsolation = + process.platform === "linux" + ? await verifyLinuxHopperLaunchIsolation({ + call, + runCli, + dispatcher: new URL("../rea.mjs", import.meta.url).pathname, + primary, + secondary, + }) + : null; await call("open_binary", { path: secondary }); const secondDocument = await call("current_document"); assert.notEqual(firstDocument, secondDocument); @@ -464,6 +475,7 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) { cliNamedSelectorParity, callerCancellationRecovered: true, closedDocumentAbsent: true, + linuxApplicationIsolation, }; } finally { await call("close_binary"); diff --git a/scripts/lib/real-hopper-session-isolation.mjs b/scripts/lib/real-hopper-session-isolation.mjs new file mode 100644 index 000000000..9630b29f5 --- /dev/null +++ b/scripts/lib/real-hopper-session-isolation.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; + +/** Prove competing Linux launches cannot forward documents into the active MCP owner. */ +export async function verifyLinuxHopperLaunchIsolation({ + call, + runCli, + dispatcher, + primary, + secondary, +}) { + const documents = await call("list_documents"); + const current = await call("current_document"); + const procedures = await call("list_procedures"); + for (const target of [primary, secondary]) { + let failure; + try { + await runCli([ + dispatcher, + "inspect", + target, + "--provider", + "hopper", + "--format", + "json", + ]); + } catch (error) { + assert.equal(error.code, 1); + failure = JSON.parse(error.stdout); + } + assert.ok( + failure, + "A competing CLI launch was allowed to reach Linux Hopper", + ); + assert.equal(failure.code, "provider_unavailable"); + assert.match( + failure.message, + /Linux Hopper is already active in REA session/u, + ); + assert.ok(failure.message.includes(target)); + assert.deepEqual( + await call("list_documents"), + documents, + "A rejected CLI launch changed the owning session's documents", + ); + assert.equal(await call("current_document"), current); + assert.deepEqual(await call("list_procedures"), procedures); + } + return { + sameTargetRejected: true, + differentTargetRejected: true, + ownerDocumentsPreserved: true, + }; +} diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 436d5f0b5..9952152f6 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -28,7 +28,10 @@ import { type HopperMachOImage, } from "./HopperMachOImage.js"; -import { acquireHopperTargetLease } from "./HopperTargetLease.js"; +import { + acquireHopperTargetLease, + acquireLinuxHopperApplicationLease, +} from "./HopperTargetLease.js"; import type { HopperTargetLease } from "./HopperTargetLease.js"; const execFileAsync = promisify(execFile); @@ -96,6 +99,7 @@ export type HopperApplicationLauncherOptions = export interface HopperApplicationLauncherDependencies { readonly platform?: NodeJS.Platform; readonly acquireTargetLease?: typeof acquireHopperTargetLease; + readonly acquireApplicationLease?: typeof acquireLinuxHopperApplicationLease; readonly selectPrivateDisplay?: (options: { readonly helperPath: string; readonly signal?: AbortSignal; @@ -131,7 +135,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { HopperStartError | HopperCancelledError | HopperProcessError > > { - const leaseResult = await this.#acquireTargetLease(session.runId); + const leaseResult = await this.#acquireLaunchLease(session.runId); if (!leaseResult.ok) return leaseResult; const lease = leaseResult.value; let leaseTransferred = false; @@ -267,32 +271,44 @@ export class HopperApplicationLauncher implements BridgeLauncher { } } - async #acquireTargetLease( + async #acquireLaunchLease( runId: string, ): Promise> { - if ((this.dependencies.platform ?? process.platform) !== "darwin") - return ok(undefined); + const platform = this.dependencies.platform ?? process.platform; + const applicationLease = + platform === "linux" && usesLinuxDemo(this.options); + if (!applicationLease && platform !== "darwin") return ok(undefined); try { - const acquisition = await ( - this.dependencies.acquireTargetLease ?? acquireHopperTargetLease - )({ - targetPath: this.options.targetPath, - targetKind: this.options.targetKind, - loaderArgs: this.options.loaderArgs, - runId, - }); + const acquisition = applicationLease + ? await ( + this.dependencies.acquireApplicationLease ?? + acquireLinuxHopperApplicationLease + )({ runId }) + : await ( + this.dependencies.acquireTargetLease ?? acquireHopperTargetLease + )({ + targetPath: this.options.targetPath, + targetKind: this.options.targetKind, + loaderArgs: this.options.loaderArgs, + runId, + }); if (!acquisition.acquired) return err( new HopperStartError({ ownerRunId: acquisition.owner.runId, - userMessage: - `This Hopper target is already open in REA session ${acquisition.owner.runId}. ` + - "Use that REA session or close it before opening this target again.", + userMessage: applicationLease + ? `Linux Hopper is already active in REA session ${acquisition.owner.runId}; it cannot open ${this.options.targetPath} in another session. This build forwards new launches to the existing application. Use the owning REA session to switch targets, or close it before retrying.` + : `This Hopper target is already open in REA session ${acquisition.owner.runId}. ` + + "Use that REA session or close it before opening this target again.", }), ); return ok(acquisition.lease); } catch (cause: unknown) { - return err(new HopperStartError({ cause })); + return err( + cause instanceof HopperStartError + ? cause + : new HopperStartError({ cause }), + ); } } diff --git a/src/hopper/HopperTargetLease.test.ts b/src/hopper/HopperTargetLease.test.ts index fd9795957..7c7bd03ae 100644 --- a/src/hopper/HopperTargetLease.test.ts +++ b/src/hopper/HopperTargetLease.test.ts @@ -1,9 +1,16 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { once } from "node:events"; +import { lstat, mkdir, mkdtemp, readdir, rm, rmdir } from "node:fs/promises"; +import { createServer, type Socket } from "node:net"; import { join } from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; -import { acquireHopperTargetLease } from "./HopperTargetLease.js"; +import { + acquireHopperTargetLease, + acquireLinuxHopperApplicationLease, +} from "./HopperTargetLease.js"; const directories: string[] = []; @@ -66,6 +73,145 @@ describe("Hopper target leases", () => { }); }); +describe("Linux Hopper application leases", () => { + it("reserves the shared application until its owner releases it", async () => { + const directory = await temporaryDirectory(); + const first = await acquireLinuxHopperApplicationLease({ + directory, + runId: "first", + }); + expect(first.acquired).toBe(true); + if (!first.acquired) return; + try { + expect( + await acquireLinuxHopperApplicationLease({ + directory, + runId: "second", + }), + ).toMatchObject({ + acquired: false, + owner: { runId: "first", processId: process.pid }, + }); + } finally { + await first.lease.release(); + } + const reopened = await acquireLinuxHopperApplicationLease({ + directory, + runId: "third", + }); + expect(reopened.acquired).toBe(true); + if (reopened.acquired) await reopened.lease.release(); + }); + + it.each(["silent", "malformed", "invalid-pid", "closed"] as const)( + "keeps a live endpoint when its owner response is %s", + async (behavior) => { + const directory = await temporaryDirectory(); + const socketPath = await releasedApplicationSocket(directory); + let responsive = false; + const sockets = new Set(); + const owner = { runId: "existing-owner", processId: process.pid }; + const server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.on("error", () => undefined); + if (responsive) socket.end(`${JSON.stringify(owner)}\n`); + else if (behavior === "malformed") socket.end("not JSON\n"); + else if (behavior === "invalid-pid") + socket.end('{"runId":"existing-owner","processId":-1}\n'); + else if (behavior === "closed") socket.end(); + }); + server.listen(socketPath); + await once(server, "listening"); + try { + await expect( + acquireLinuxHopperApplicationLease({ directory, runId: "new-owner" }), + ).rejects.toMatchObject({ + _tag: "HopperStartError", + userMessage: expect.stringContaining("left unchanged"), + }); + expect((await lstat(socketPath)).isSocket()).toBe(true); + responsive = true; + expect( + await acquireLinuxHopperApplicationLease({ + directory, + runId: "retry", + }), + ).toEqual({ + acquired: false, + owner, + }); + } finally { + for (const socket of sockets) socket.destroy(); + server.close(); + await once(server, "close"); + } + }, + ); + + it("recovers a socket left by a process that exited without releasing it", async () => { + const directory = await temporaryDirectory(); + const socketPath = await releasedApplicationSocket(directory); + await leaveStaleSocket(socketPath); + expect((await lstat(socketPath)).isSocket()).toBe(true); + const recovered = await acquireLinuxHopperApplicationLease({ + directory, + runId: "recovered", + }); + expect(recovered.acquired).toBe(true); + if (recovered.acquired) await recovered.lease.release(); + }); +}); + +it("leaves a stale socket unchanged while another process reserves its recovery", async () => { + const directory = await temporaryDirectory(); + const socketPath = await releasedApplicationSocket(directory); + await leaveStaleSocket(socketPath); + const recoveryPath = `${socketPath}.recovery`; + await mkdir(recoveryPath, { mode: 0o700 }); + const stale = await lstat(socketPath); + await expect( + acquireLinuxHopperApplicationLease({ directory, runId: "competing" }), + ).rejects.toMatchObject({ + _tag: "HopperStartError", + userMessage: expect.stringContaining(recoveryPath), + }); + expect((await lstat(socketPath)).ino).toBe(stale.ino); + await rmdir(recoveryPath); + const recovered = await acquireLinuxHopperApplicationLease({ + directory, + runId: "after-recovery", + }); + expect(recovered.acquired).toBe(true); + if (recovered.acquired) await recovered.lease.release(); + expect(await readdir(directory)).toEqual([]); +}); + +const leaveStaleSocket = async (socketPath: string): Promise => { + await promisify(execFile)(process.execPath, [ + "--input-type=module", + "-e", + 'import {createServer} from "node:net"; createServer().listen(process.argv[1],()=>process.exit(0));', + socketPath, + ]); +}; + +const releasedApplicationSocket = async ( + directory: string, +): Promise => { + const initial = await acquireLinuxHopperApplicationLease({ + directory, + runId: "initial", + }); + if (!initial.acquired) + throw new Error("Initial application lease was unavailable"); + const [name] = await readdir(directory); + await initial.lease.release(); + if (name === undefined) + throw new Error("Application lease did not create a socket"); + return join(directory, name); +}; + const leaseInput = { targetPath: "/tmp/sample-binary", targetKind: "executable" as const, diff --git a/src/hopper/HopperTargetLease.ts b/src/hopper/HopperTargetLease.ts index 3d5d808e6..ad291ef22 100644 --- a/src/hopper/HopperTargetLease.ts +++ b/src/hopper/HopperTargetLease.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmod, lstat, mkdir, realpath, rm } from "node:fs/promises"; +import { chmod, lstat, mkdir, realpath, rm, rmdir } from "node:fs/promises"; import { createConnection, createServer, @@ -23,7 +23,7 @@ export type HopperTargetLeaseAcquisition = | { readonly acquired: true; readonly lease: HopperTargetLease } | { readonly acquired: false; readonly owner: HopperTargetLeaseOwner }; -/** Shared private parent of per-target lease sockets; it outlives individual sessions. */ +/** Shared private parent of Hopper lease sockets; it outlives individual sessions. */ export const HOPPER_TARGET_LEASE_DIRECTORY = join( "/tmp", `rea-hopper-${process.getuid?.() ?? 0}`, @@ -37,8 +37,6 @@ export const acquireHopperTargetLease = async (input: { readonly runId: string; readonly directory?: string; }): Promise => { - const directory = input.directory ?? HOPPER_TARGET_LEASE_DIRECTORY; - await ensureLeaseDirectory(directory); // The canonical path only derives the lease key; it never authorizes access. // Common expected `realpath` failures include a missing target or path // component (ENOENT, ENOTDIR), an unreadable component (EACCES), or a @@ -49,48 +47,115 @@ export const acquireHopperTargetLease = async (input: { const targetPath = await realpath(input.targetPath).catch(() => resolve(input.targetPath), ); + return acquireLease({ + ...input, + identity: { + targetPath, + targetKind: input.targetKind, + loaderArgs: input.loaderArgs, + }, + }); +}; + +/** Reserve the Linux demo's singleton application before it can forward a target to another owner. */ +export const acquireLinuxHopperApplicationLease = (input: { + readonly runId: string; + readonly directory?: string; +}): Promise => + acquireLease({ ...input, identity: { application: "linux-hopper-demo" } }); + +const acquireLease = async (input: { + readonly identity: Readonly>; + readonly runId: string; + readonly directory?: string; +}): Promise => { + const directory = input.directory ?? HOPPER_TARGET_LEASE_DIRECTORY; + await ensureLeaseDirectory(directory); const key = createHash("sha256") - .update( - JSON.stringify({ - targetPath, - targetKind: input.targetKind, - loaderArgs: input.loaderArgs, - }), - ) + .update(JSON.stringify(input.identity)) .digest("hex") .slice(0, 32); const socketPath = join(directory, `${key}.sock`); const owner = { runId: input.runId, processId: process.pid }; + const acquisition = await tryAcquireLease(socketPath, owner); + if (typeof acquisition !== "string") return acquisition; - for (let attempt = 0; attempt < 2; attempt += 1) { - const server = createServer((socket) => sendOwner(socket, owner)); - const bound = await bind(server, socketPath); - if (bound) { - await chmod(socketPath, 0o600); - let released = false; - return { - acquired: true, - lease: { - release: async () => { - if (released) return; - released = true; - await closeServer(server); - await rm(socketPath, { force: true }); - }, - }, - }; + // Serialize stale cleanup through the replacement bind: otherwise two + // recoverers can unlink the socket that one of them has just acquired. + const recoveryPath = `${socketPath}.recovery`; + await reserveRecovery(recoveryPath); + let recovered: HopperTargetLeaseAcquisition | "missing" | "stale" | undefined; + try { + recovered = await tryAcquireLease(socketPath, owner); + if (recovered === "stale") await rm(socketPath, { force: true }); + if (typeof recovered === "string") + recovered = await tryAcquireLease(socketPath, owner); + if (typeof recovered !== "string") return recovered; + throw new HopperStartError({ + userMessage: + "REA could not reserve Hopper for this request. Close competing REA sessions and retry.", + }); + } finally { + try { + await rmdir(recoveryPath); + } catch (cause: unknown) { + if (typeof recovered !== "string" && recovered?.acquired) + await recovered.lease.release(); + throw new HopperStartError({ + cause, + userMessage: `REA could not remove its Hopper recovery directory at ${JSON.stringify(recoveryPath)}. Check this directory's write access before retrying.`, + }); } - - const existingOwner = await readOwner(socketPath); - if (existingOwner !== undefined) - return { acquired: false, owner: existingOwner }; - await rm(socketPath, { force: true }); } +}; - throw new HopperStartError({ - userMessage: - "REA could not reserve this Hopper target. Close competing REA sessions and retry.", - }); +const reserveRecovery = async (recoveryPath: string): Promise => { + try { + await mkdir(recoveryPath, { mode: 0o700 }); + } catch (cause: unknown) { + const busy = + typeof cause === "object" && + cause !== null && + Reflect.get(cause, "code") === "EEXIST"; + throw new HopperStartError({ + cause, + userMessage: busy + ? `REA could not reserve Hopper lease recovery at ${JSON.stringify(recoveryPath)}; the existing socket was left unchanged. Retry after concurrent startup finishes. If recovery was interrupted, stop REA sessions and remove this recovery directory before retrying.` + : `REA could not create its Hopper recovery directory at ${JSON.stringify(recoveryPath)}; the existing socket was left unchanged. Check the parent directory's write access before retrying.`, + }); + } +}; + +const tryAcquireLease = async ( + socketPath: string, + owner: HopperTargetLeaseOwner, +): Promise => { + const server = createServer((socket) => sendOwner(socket, owner)); + if (!(await bind(server, socketPath))) { + const existingOwner = await readOwner(socketPath); + return typeof existingOwner === "string" + ? existingOwner + : { acquired: false, owner: existingOwner }; + } + try { + await chmod(socketPath, 0o600); + } catch (cause: unknown) { + await closeServer(server); + throw cause; + } + let released = false; + return { + acquired: true, + lease: { + release: async () => { + if (released) return; + released = true; + // Node removes a bound Unix socket on close. A second unlink here + // could remove a new owner's socket bound after that close. + await closeServer(server); + }, + }, + }; }; const ensureLeaseDirectory = async (directory: string): Promise => { @@ -139,15 +204,29 @@ const sendOwner = (socket: Socket, owner: HopperTargetLeaseOwner): void => { const readOwner = ( socketPath: string, -): Promise => - new Promise((resolveOwner) => { +): Promise => + new Promise((resolveOwner, rejectOwner) => { const socket = createConnection(socketPath); let response = ""; - const timer = setTimeout(() => { + let settled = false; + const fail = (reason: string, cause?: unknown): void => { + if (settled) return; + settled = true; + clearTimeout(timer); socket.destroy(); - resolveOwner(undefined); - }, 250); - const finish = (owner: HopperTargetLeaseOwner | undefined): void => { + rejectOwner( + new HopperStartError({ + cause, + userMessage: `REA could not verify the existing Hopper lease (${reason}); it was left unchanged. Retry when the owning REA session is responsive.`, + }), + ); + }; + const timer = setTimeout(() => fail("owner response timed out"), 250); + const finish = ( + owner: HopperTargetLeaseOwner | "missing" | "stale", + ): void => { + if (settled) return; + settled = true; clearTimeout(timer); socket.destroy(); resolveOwner(owner); @@ -158,24 +237,34 @@ const readOwner = ( if (newline < 0) return; const parsed = safeParseJson(response.slice(0, newline)); if (!parsed.ok) { - finish(undefined); + fail("malformed owner response"); return; } const value: unknown = parsed.value; + const runId = + typeof value === "object" && value !== null + ? Reflect.get(value, "runId") + : undefined; + const processId = + typeof value === "object" && value !== null + ? Reflect.get(value, "processId") + : undefined; if ( - typeof value === "object" && - value !== null && - typeof Reflect.get(value, "runId") === "string" && - typeof Reflect.get(value, "processId") === "number" + typeof runId === "string" && + runId.length > 0 && + typeof processId === "number" && + Number.isSafeInteger(processId) && + processId > 0 ) - finish({ - runId: Reflect.get(value, "runId") as string, - processId: Reflect.get(value, "processId") as number, - }); - else finish(undefined); + finish({ runId, processId }); + else fail("invalid owner identity"); }); - socket.on("error", () => finish(undefined)); - socket.on("end", () => finish(undefined)); + socket.on("error", (cause: NodeJS.ErrnoException) => { + if (cause.code === "ENOENT") finish("missing"); + else if (cause.code === "ECONNREFUSED") finish("stale"); + else fail(cause.code ?? "owner connection failed", cause); + }); + socket.on("end", () => fail("owner closed without a complete response")); }); const closeServer = (server: Server): Promise => diff --git a/tests/boundary/providers/hopper/bridgeTargetLease.integration.test.ts b/tests/boundary/providers/hopper/bridgeTargetLease.integration.test.ts index 019947aa5..c82243e8e 100644 --- a/tests/boundary/providers/hopper/bridgeTargetLease.integration.test.ts +++ b/tests/boundary/providers/hopper/bridgeTargetLease.integration.test.ts @@ -1,8 +1,87 @@ import { describe, expect, it } from "vitest"; import { HopperApplicationLauncher } from "../../../../src/hopper/BridgeLauncher.js"; +import { HopperStartError } from "../../../../src/domain/hopperErrors.js"; -describe("Hopper target leases in the launcher", () => { +describe("Linux Hopper application leases in the launcher", () => { + it.each(["/first-target", "/different-target"])( + "rejects Linux demo launch for %s before it can forward a document to another owner", + async (targetPath) => { + let probedDisplay = false; + const launcher = new HopperApplicationLauncher( + { + launcherPath: "/unused/Hopper", + targetPath, + targetKind: "executable", + loaderArgs: [], + bridgeScriptPath: "/rea/hopper_bridge.py", + launchMode: "verified_linux_demo", + demoHelperPath: "/rea/hopper-demo-x11.py", + }, + { + platform: "linux", + acquireApplicationLease: async () => ({ + acquired: false, + owner: { runId: "owning-session", processId: 17 }, + }), + selectPrivateDisplay: async () => { + probedDisplay = true; + throw new Error("Launch must stop before probing the display"); + }, + }, + ); + expect( + await launcher.launch({ + directory: "/missing/rea-launch", + socketPath: "/missing/rea-launch/bridge.sock", + token: "token", + runId: "new-session", + }), + ).toMatchObject({ + ok: false, + error: { + _tag: "HopperStartError", + ownerRunId: "owning-session", + userMessage: expect.stringContaining(targetPath), + }, + }); + expect(probedDisplay).toBe(false); + }, + ); + + it("preserves the reason that an existing lease could not be verified", async () => { + const failure = new HopperStartError({ + userMessage: "Existing owner timed out; the lease was left unchanged", + }); + const launcher = new HopperApplicationLauncher( + { + launcherPath: "/unused/Hopper", + targetPath: "/target", + targetKind: "executable", + loaderArgs: [], + bridgeScriptPath: "/rea/hopper_bridge.py", + launchMode: "verified_linux_demo", + demoHelperPath: "/rea/helper.py", + }, + { + platform: "linux", + acquireApplicationLease: async () => { + throw failure; + }, + }, + ); + expect( + await launcher.launch({ + directory: "/missing/rea-launch", + socketPath: "/missing/rea-launch/bridge.sock", + token: "token", + runId: "new-session", + }), + ).toEqual({ ok: false, error: failure }); + }); +}); + +describe("Hopper launch leases", () => { it("reports an active target owner without launching another document", async () => { let leaseChecks = 0; const launcher = new HopperApplicationLauncher( @@ -45,34 +124,46 @@ describe("Hopper target leases in the launcher", () => { }); }); - it("releases a target lease when bridge setup fails", async () => { - let releases = 0; - const launcher = new HopperApplicationLauncher( - { - launcherPath: "/unused/hopper", - targetPath: "/target", - targetKind: "executable", - loaderArgs: [], - bridgeScriptPath: "/rea/hopper_bridge.py", - launchMode: "native", - }, - { - platform: "darwin", - acquireTargetLease: async () => ({ - acquired: true, - lease: { release: async () => void (releases += 1) }, - }), - }, - ); + it.each(["darwin", "linux"] as const)( + "releases the %s launch lease when bridge setup fails", + async (platform) => { + let releases = 0; + const launcher = new HopperApplicationLauncher( + { + launcherPath: "/unused/hopper", + targetPath: "/target", + targetKind: "executable", + loaderArgs: [], + bridgeScriptPath: "/rea/hopper_bridge.py", + ...(platform === "linux" + ? { + launchMode: "verified_linux_demo" as const, + demoHelperPath: "/unused/helper.py", + } + : { launchMode: "native" as const }), + }, + { + platform, + acquireTargetLease: async () => ({ + acquired: true, + lease: { release: async () => void (releases += 1) }, + }), + acquireApplicationLease: async () => ({ + acquired: true, + lease: { release: async () => void (releases += 1) }, + }), + }, + ); - const result = await launcher.launch({ - directory: "/missing/rea-launch-test", - socketPath: "/missing/rea-launch-test/bridge.sock", - token: "token", - runId: "failed-session", - }); + const result = await launcher.launch({ + directory: "/missing/rea-launch-test", + socketPath: "/missing/rea-launch-test/bridge.sock", + token: "token", + runId: "failed-session", + }); - expect(result.ok).toBe(false); - expect(releases).toBe(1); - }); + expect(result.ok).toBe(false); + expect(releases).toBe(1); + }, + ); }); diff --git a/tests/conformance/c/fixture.c b/tests/conformance/c/fixture.c index 66d485436..6708ac9dd 100644 --- a/tests/conformance/c/fixture.c +++ b/tests/conformance/c/fixture.c @@ -25,7 +25,9 @@ __attribute__((noinline, used)) int rea_branch(int value) { __attribute__((noinline, used)) int rea_entry(void) { puts("REA_C_ENTRY"); - return rea_branch(5); + /* Give the native locals oracle explicit stack storage. */ + volatile int input = 5; + return rea_branch(input); } int main(void) { return rea_entry() == 12 ? 0 : 1; }