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
This commit is contained in:
Ysopwnable 2026-10-09 10:14:25 +08:00 committed by GitHub
parent b620e2a2bd
commit 33d5ae0b52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 532 additions and 105 deletions

View File

@ -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

View File

@ -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

View File

@ -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");

View File

@ -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,
};
}

View File

@ -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<Result<HopperTargetLease | undefined, HopperStartError>> {
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 }),
);
}
}

View File

@ -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<Socket>();
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<void> => {
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<string> => {
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,

View File

@ -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<HopperTargetLeaseAcquisition> => {
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<HopperTargetLeaseAcquisition> =>
acquireLease({ ...input, identity: { application: "linux-hopper-demo" } });
const acquireLease = async (input: {
readonly identity: Readonly<Record<string, unknown>>;
readonly runId: string;
readonly directory?: string;
}): Promise<HopperTargetLeaseAcquisition> => {
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<void> => {
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<HopperTargetLeaseAcquisition | "missing" | "stale"> => {
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<void> => {
@ -139,15 +204,29 @@ const sendOwner = (socket: Socket, owner: HopperTargetLeaseOwner): void => {
const readOwner = (
socketPath: string,
): Promise<HopperTargetLeaseOwner | undefined> =>
new Promise((resolveOwner) => {
): Promise<HopperTargetLeaseOwner | "missing" | "stale"> =>
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<void> =>

View File

@ -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);
},
);
});

View File

@ -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; }