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:
parent
b620e2a2bd
commit
33d5ae0b52
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue