mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
fix(hopper): preserve startup failures and recover denied launches
Preserve the primary Hopper startup failure and launcher diagnostics while retaining resources when cleanup cannot be confirmed. Reauthenticate matching external application sessions before document cleanup, keep the Python bridge available for bounded reconnects, and expose the provider-owned Hopper startup deadline. Real headless Linux Hopper semantic analysis and 65 focused configuration, handshake, cleanup, and bridge tests passed. macOS authorization/crash causes and saved-HOP behavior require their own real-provider verification. Refs #1808 Co-authored-by: N0zoM1z0 <[email protected]> Co-authored-by: Kaoru0822 <[email protected]>
This commit is contained in:
co-authored by
N0zoM1z0
Kaoru0822
parent
ee5275cd9f
commit
069308bbe6
@@ -181,8 +181,8 @@ Native formats and host support vary by provider. See
|
||||
[IDA guide](docs/ida-provider.md), and
|
||||
[experimental Windows Ghidra support](docs/windows-ghidra-p0.md).
|
||||
Ghidra also supports [16-bit DOS analysis](docs/ghidra-dos.md).
|
||||
For large binaries, raise its startup deadline with
|
||||
`REA_GHIDRA_STARTUP_TIMEOUT_MS`.
|
||||
For large binaries, raise the provider's startup deadline with
|
||||
`REA_GHIDRA_STARTUP_TIMEOUT_MS` or `REA_HOPPER_STARTUP_TIMEOUT_MS`.
|
||||
For provider selection, see the [CLI guide](docs/cli.md#choose-a-provider).
|
||||
Check [release availability](docs/installation.md#released-package-and-main)
|
||||
for features added since the latest npm release.
|
||||
|
||||
+89
-66
@@ -1016,73 +1016,87 @@ def _dispatch(method, params):
|
||||
def _serve_connection(connection):
|
||||
"""Serve one capability-authenticated NDJSON connection."""
|
||||
file = connection.makefile("rwb")
|
||||
while True:
|
||||
line = file.readline()
|
||||
if not line:
|
||||
break
|
||||
request_id = None
|
||||
authenticated = False
|
||||
should_stop = False
|
||||
try:
|
||||
should_stop = False
|
||||
try:
|
||||
while True:
|
||||
line = file.readline()
|
||||
if not line:
|
||||
break
|
||||
request_id = None
|
||||
authenticated = False
|
||||
should_stop = False
|
||||
try:
|
||||
request = json.loads(line.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise InvalidRequestError("Invalid bridge request JSON") from error
|
||||
if not isinstance(request, dict) or set(request) != {"id", "token", "method", "params"}:
|
||||
raise InvalidRequestError("Invalid bridge request shape")
|
||||
request_id = request["id"]
|
||||
if isinstance(request_id, bool) or not isinstance(request_id, int) or request_id < 0:
|
||||
raise InvalidRequestError("Invalid bridge request id")
|
||||
if (
|
||||
not isinstance(request["method"], str) or not request["method"]
|
||||
or not isinstance(request["params"], dict)
|
||||
):
|
||||
raise InvalidRequestError("Invalid bridge method or parameters")
|
||||
if (
|
||||
not isinstance(request["token"], str) or not request["token"].isascii()
|
||||
or not hmac.compare_digest(request["token"], REA_TOKEN)
|
||||
):
|
||||
raise PermissionError("Invalid bridge capability")
|
||||
authenticated = True
|
||||
_write_message(file, {"id": request_id, "event": {
|
||||
"type": "progress",
|
||||
"phase": "hopper_bridge",
|
||||
"completed": 0,
|
||||
"total": 1,
|
||||
"message": "Hopper bridge started request",
|
||||
}})
|
||||
result = _dispatch(request["method"], request["params"])
|
||||
should_stop = request["method"] == "shutdown_document" or (
|
||||
request["method"] == "shutdown" and not result.get("cleanup_required", False)
|
||||
)
|
||||
safe_result = _json_safe(result)
|
||||
_write_message(file, {"id": request_id, "event": {
|
||||
"type": "progress",
|
||||
"phase": "hopper_bridge",
|
||||
"completed": 1,
|
||||
"total": 1,
|
||||
"message": "Hopper bridge completed request",
|
||||
"terminal": True,
|
||||
}})
|
||||
response = {"id": request_id, "result": safe_result}
|
||||
except Exception as error:
|
||||
diagnostic = _safe_diagnostic(error)
|
||||
if authenticated:
|
||||
try:
|
||||
request = json.loads(line.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise InvalidRequestError("Invalid bridge request JSON") from error
|
||||
if not isinstance(request, dict) or set(request) != {"id", "token", "method", "params"}:
|
||||
raise InvalidRequestError("Invalid bridge request shape")
|
||||
request_id = request["id"]
|
||||
if isinstance(request_id, bool) or not isinstance(request_id, int) or request_id < 0:
|
||||
raise InvalidRequestError("Invalid bridge request id")
|
||||
if (
|
||||
not isinstance(request["method"], str) or not request["method"]
|
||||
or not isinstance(request["params"], dict)
|
||||
):
|
||||
raise InvalidRequestError("Invalid bridge method or parameters")
|
||||
if (
|
||||
not isinstance(request["token"], str) or not request["token"].isascii()
|
||||
or not hmac.compare_digest(request["token"], REA_TOKEN)
|
||||
):
|
||||
raise PermissionError("Invalid bridge capability")
|
||||
authenticated = True
|
||||
_write_message(file, {"id": request_id, "event": {
|
||||
"type": "diagnostic",
|
||||
"error": diagnostic,
|
||||
"type": "progress",
|
||||
"phase": "hopper_bridge",
|
||||
"completed": 0,
|
||||
"total": 1,
|
||||
"message": "Hopper bridge started request",
|
||||
}})
|
||||
response_id = request_id if (
|
||||
isinstance(request_id, int)
|
||||
and not isinstance(request_id, bool)
|
||||
and request_id >= 0
|
||||
) else 0
|
||||
response = {"id": response_id, "error": diagnostic}
|
||||
_write_message(file, response)
|
||||
if should_stop:
|
||||
break
|
||||
file.close()
|
||||
connection.close()
|
||||
result = _dispatch(request["method"], request["params"])
|
||||
should_stop = request["method"] in ("shutdown", "shutdown_document") and (
|
||||
result.get("shutdown") is True and (
|
||||
(result.get("document_closed") is True and result.get("analysis_stopped") is True)
|
||||
or (result.get("document_closed") is False and result.get("document_retained") is True)
|
||||
)
|
||||
)
|
||||
safe_result = _json_safe(result)
|
||||
_write_message(file, {"id": request_id, "event": {
|
||||
"type": "progress",
|
||||
"phase": "hopper_bridge",
|
||||
"completed": 1,
|
||||
"total": 1,
|
||||
"message": "Hopper bridge completed request",
|
||||
"terminal": True,
|
||||
}})
|
||||
response = {"id": request_id, "result": safe_result}
|
||||
except Exception as error:
|
||||
diagnostic = _safe_diagnostic(error)
|
||||
if authenticated:
|
||||
_write_message(file, {"id": request_id, "event": {
|
||||
"type": "diagnostic",
|
||||
"error": diagnostic,
|
||||
}})
|
||||
response_id = request_id if (
|
||||
isinstance(request_id, int)
|
||||
and not isinstance(request_id, bool)
|
||||
and request_id >= 0
|
||||
) else 0
|
||||
response = {"id": response_id, "error": diagnostic}
|
||||
_write_message(file, response)
|
||||
if should_stop:
|
||||
break
|
||||
except (ConnectionError, OSError):
|
||||
# A lost client cannot confirm document closure or revoke this run.
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
file.close()
|
||||
except OSError:
|
||||
# Closing a failed writer can repeat the same disconnected-pipe error.
|
||||
pass
|
||||
connection.close()
|
||||
return should_stop
|
||||
|
||||
|
||||
def _write_message(file, message):
|
||||
@@ -1124,8 +1138,17 @@ def _run():
|
||||
os.chmod(REA_SOCKET, 0o600)
|
||||
server.listen(1)
|
||||
try:
|
||||
connection, _ = server.accept()
|
||||
_serve_connection(connection)
|
||||
while True:
|
||||
try:
|
||||
connection, _ = server.accept()
|
||||
except socket.timeout:
|
||||
break
|
||||
connection.settimeout(None)
|
||||
if _serve_connection(connection):
|
||||
break
|
||||
# Match REA's cleanup handshake budget without pinning Hopper's
|
||||
# Python thread indefinitely after a client disappears.
|
||||
server.settimeout(REA_RECONNECT_TIMEOUT_SECONDS)
|
||||
finally:
|
||||
server.close()
|
||||
if os.path.exists(REA_SOCKET):
|
||||
|
||||
@@ -531,6 +531,25 @@ export HOPPER_LAUNCHER_PATH=/absolute/path/to/Hopper
|
||||
rea doctor --provider hopper --json
|
||||
```
|
||||
|
||||
Hopper startup allows 120,000 ms for launch, initial analysis, and authenticated
|
||||
bridge readiness. For large targets, set `REA_HOPPER_STARTUP_TIMEOUT_MS` to a
|
||||
decimal integer from 1 to 2,147,483,647 milliseconds in the environment that
|
||||
starts REA. CLI and MCP use the same setting; invalid values fail configuration
|
||||
validation. An MCP client's own request timeout must also allow enough time.
|
||||
|
||||
If macOS denies Apple Events, allow the application that starts REA under
|
||||
**System Settings → Privacy & Security → Automation**, then retry. REA releases
|
||||
the target reservation when the native helper reports its complete authorization
|
||||
rejection and its arguments cannot alter the helper's generated AppleScript.
|
||||
Arguments containing quotes, backslashes, or line breaks remain unchanged but
|
||||
cannot establish that a failed handoff opened no document. Other failed handoffs
|
||||
retain ownership until document closure is confirmed. If a timed-out bridge
|
||||
becomes ready later, `close_binary` connects to that run, authenticates its
|
||||
identity, and requests document shutdown. After a connection drops, the bridge
|
||||
allows 30 seconds for an authenticated cleanup reconnect, then releases Hopper's
|
||||
Python execution thread. Expiry does not establish document closure, so REA
|
||||
retains unconfirmed ownership. Retry close while the bridge remains available.
|
||||
|
||||
If a Linux launcher exists but cannot start, inspect its shared libraries:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -204,6 +204,7 @@ These deadlines have different owners:
|
||||
| Deadline | Owner and effect |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MCP initialize | The client bounds transport/REA connection startup, before any target query. |
|
||||
| Hopper startup | REA allows 120,000 ms by default; `REA_HOPPER_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. Startup failure is reported by the first provider query. |
|
||||
| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid supplied values fail configuration validation. Startup failure is reported by the first provider query. |
|
||||
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("runtime configuration", () => {
|
||||
if (empty.ok) {
|
||||
expect(empty.value.hopperTargetPath).toBeUndefined();
|
||||
expect(empty.value.analysisProvider).toBe("auto");
|
||||
expect(empty.value.hopperStartupTimeoutMs).toBe(120_000);
|
||||
}
|
||||
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -170,6 +171,40 @@ describe("runtime collection configuration", () => {
|
||||
});
|
||||
|
||||
describe("configuration failure diagnostics", () => {
|
||||
it.each(["1", "900000", "2147483647"])(
|
||||
"accepts Hopper startup deadline %s without changing Ghidra's deadline",
|
||||
(value) => {
|
||||
expect(
|
||||
parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: value }),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
hopperStartupTimeoutMs: Number(value),
|
||||
ghidraStartupTimeoutMs: 330_000,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])(
|
||||
"rejects malformed Hopper startup deadline %j with its setting",
|
||||
(value) => {
|
||||
const result = parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: value });
|
||||
if (result.ok) throw new Error("Expected invalid startup deadline");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "configuration_invalid",
|
||||
details: {
|
||||
settings: [
|
||||
{
|
||||
setting: "REA_HOPPER_STARTUP_TIMEOUT_MS",
|
||||
constraint: expect.any(String),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"",
|
||||
" ",
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
ghidraCompilerSpecIdSchema,
|
||||
ghidraLanguageIdSchema,
|
||||
} from "./ghidraLanguageOverride.js";
|
||||
import { ghidraStartupTimeoutSchema } from "./ghidraStartupTimeout.js";
|
||||
import {
|
||||
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
|
||||
providerStartupTimeoutSchema,
|
||||
} from "./providerStartupTimeout.js";
|
||||
import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js";
|
||||
import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js";
|
||||
|
||||
@@ -36,7 +40,12 @@ const environmentSchema = z.object({
|
||||
.min(1)
|
||||
.refine(isAbsolute, "REA_GHIDRA_NATIVEAOT_JAR must be absolute")
|
||||
.optional(),
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: ghidraStartupTimeoutSchema,
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default(
|
||||
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
),
|
||||
REA_HOPPER_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default(
|
||||
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
|
||||
),
|
||||
REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(),
|
||||
REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(),
|
||||
REA_GHIDRA_SEED_FILE: z
|
||||
|
||||
@@ -98,6 +98,7 @@ export const parseConfig = (
|
||||
process.platform,
|
||||
homeDirectoryFromEnvironment(environment, process.platform),
|
||||
),
|
||||
hopperStartupTimeoutMs: env.REA_HOPPER_STARTUP_TIMEOUT_MS,
|
||||
hopperTargetPath: env.HOPPER_TARGET_PATH,
|
||||
hopperTargetKind: env.HOPPER_TARGET_KIND,
|
||||
hopperLoaderArgs: loaderArgs.value,
|
||||
|
||||
@@ -3,13 +3,15 @@ import { z } from "zod";
|
||||
/** Default absolute deadline for Ghidra import, analysis, bridge, and readiness. */
|
||||
export const DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS = 330_000;
|
||||
|
||||
/** Default absolute deadline for Hopper launch, analysis, bridge, and readiness. */
|
||||
export const DEFAULT_HOPPER_STARTUP_TIMEOUT_MS = 120_000;
|
||||
|
||||
// Node schedules delays above this signed 32-bit boundary as a 1 ms timer.
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
/** Parse the optional setting without reading the ambient process environment. */
|
||||
export const ghidraStartupTimeoutSchema = z
|
||||
export const providerStartupTimeoutSchema = z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u, "must be a positive decimal integer")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().min(1).max(MAX_TIMER_DELAY_MS))
|
||||
.default(DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS);
|
||||
.pipe(z.number().int().min(1).max(MAX_TIMER_DELAY_MS));
|
||||
@@ -18,6 +18,7 @@ export interface AppConfig {
|
||||
readonly ghidraNativeAotJar?: string;
|
||||
readonly ilspyCmdPath: string | undefined;
|
||||
readonly hopperLauncherPath: string;
|
||||
readonly hopperStartupTimeoutMs: number;
|
||||
readonly hopperTargetPath: string | undefined;
|
||||
readonly hopperTargetKind: "executable" | "database";
|
||||
readonly hopperLoaderArgs: readonly string[];
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type ProviderProcessSnapshot,
|
||||
ProviderProcessSupervisor,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS } from "../config/ghidraStartupTimeout.js";
|
||||
import { DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS } from "../config/providerStartupTimeout.js";
|
||||
import type {
|
||||
GhidraClientOptions,
|
||||
GhidraRequestOptions,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
acquireLinuxHopperApplicationLease,
|
||||
} from "./HopperTargetLease.js";
|
||||
import type { HopperTargetLease } from "./HopperTargetLease.js";
|
||||
import { HOPPER_SHUTDOWN_TIMEOUT_MS } from "./HopperSessionValues.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const HOPPER_BACKGROUND_STARTUP_MS = 5_000;
|
||||
@@ -46,6 +47,8 @@ export interface BridgeSession {
|
||||
|
||||
/** Process handle returned by a bridge launcher. */
|
||||
export type BridgeLaunch = {
|
||||
/** Only literal native-helper arguments support a pre-handoff denial conclusion. */
|
||||
readonly authorizationDenialBeforeHandoff?: true;
|
||||
/** Release application/target exclusivity only after verified document and process cleanup. */
|
||||
readonly releaseLease?: () => Promise<void>;
|
||||
/** Exact launch command retained in local ownership metadata. */
|
||||
@@ -254,6 +257,15 @@ export class HopperApplicationLauncher implements BridgeLauncher {
|
||||
...ownedLauncher,
|
||||
providerLifetime: "external-application",
|
||||
shutdownMode: "bridge-request",
|
||||
// The vendor helper interpolates these values into AppleScript
|
||||
// literals without escaping. Preserve them, but withhold the
|
||||
// pre-handoff conclusion when they can alter that script.
|
||||
...((this.dependencies.platform ?? process.platform) === "darwin" &&
|
||||
![this.options.launcherPath, ...argumentsForTarget].some((value) =>
|
||||
/["\\\r\n]/u.test(value),
|
||||
)
|
||||
? { authorizationDenialBeforeHandoff: true as const }
|
||||
: {}),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(hopperLaunchFailure(cause, signal));
|
||||
@@ -370,6 +382,7 @@ const bridgeBootstrapSource = (
|
||||
`REA_RUN_ID = ${JSON.stringify(session.runId)}`,
|
||||
`REA_TARGET_PATH = ${JSON.stringify(loadedPath)}`,
|
||||
`REA_OWNS_PROCESS_LIFETIME = ${ownsProcessLifetime ? "True" : "False"}`,
|
||||
`REA_RECONNECT_TIMEOUT_SECONDS = ${HOPPER_SHUTDOWN_TIMEOUT_MS / 1_000}`,
|
||||
`exec(compile(open(${JSON.stringify(options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(options.bridgeScriptPath)}, 'exec'))`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
+104
-35
@@ -50,10 +50,15 @@ import {
|
||||
} from "./HopperCleanup.js";
|
||||
import {
|
||||
parseHopperServerInfo,
|
||||
HOPPER_SHUTDOWN_TIMEOUT_MS,
|
||||
type HopperServerInfo,
|
||||
} from "./HopperSessionValues.js";
|
||||
import { connectHopperSocketOnce } from "./HopperSocketConnection.js";
|
||||
import { hopperLauncherFailureDiagnostic } from "./HopperProcessDiagnostic.js";
|
||||
import {
|
||||
hopperLauncherFailureDiagnostic,
|
||||
nativeHopperLaunchWasDenied,
|
||||
} from "./HopperProcessDiagnostic.js";
|
||||
import { DEFAULT_HOPPER_STARTUP_TIMEOUT_MS } from "../config/providerStartupTimeout.js";
|
||||
import {
|
||||
HopperRequestQueue,
|
||||
type HopperRequestActivity,
|
||||
@@ -68,7 +73,6 @@ import {
|
||||
/** Aggregate in-memory retention budget for this provider's launcher output. */
|
||||
export const HOPPER_PROCESS_DIAGNOSTIC_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 30_000;
|
||||
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
|
||||
|
||||
/** Dependencies, deadlines, and redacted diagnostics for one bridge client. */
|
||||
@@ -114,6 +118,8 @@ export class HopperClient {
|
||||
#launcherFailureDiagnostic: HopperStartupFailureDiagnostic | undefined;
|
||||
#operationFailure: OperationFailureRecord | undefined;
|
||||
#startupController: AbortController | undefined;
|
||||
#startupFailureResult: AnalysisError | undefined;
|
||||
#runId: string | undefined;
|
||||
#startPromise: Promise<Result<HopperServerInfo, AnalysisError>> | undefined;
|
||||
#closePromise: Promise<Result<null, AnalysisError>> | undefined;
|
||||
readonly #onSocketData = (chunk: string): void => {
|
||||
@@ -169,7 +175,8 @@ export class HopperClient {
|
||||
this.#logger = options.logger ?? silentLogger;
|
||||
this.#options = {
|
||||
...options,
|
||||
startupTimeoutMs: options.startupTimeoutMs ?? 120_000,
|
||||
startupTimeoutMs:
|
||||
options.startupTimeoutMs ?? DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
|
||||
};
|
||||
this.#requests = new HopperRequestQueue(
|
||||
({ id, method, params }, failed) => {
|
||||
@@ -311,14 +318,18 @@ export class HopperClient {
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<HopperServerInfo, AnalysisError>> {
|
||||
if (isAborted(signal)) return err(new HopperCancelledError());
|
||||
this.#operationFailure = undefined;
|
||||
if (
|
||||
this.#socket !== undefined ||
|
||||
this.#resources.launch !== undefined ||
|
||||
this.#resources.runtimeRoot !== undefined
|
||||
) {
|
||||
return err(new HopperProtocolError("Hopper client is already started"));
|
||||
return err(
|
||||
this.#startupFailureResult ??
|
||||
new HopperProtocolError("Hopper client is already started"),
|
||||
);
|
||||
}
|
||||
this.#operationFailure = undefined;
|
||||
this.#startupFailureResult = undefined;
|
||||
this.#resources.shutdownConfirmed = false;
|
||||
const deadline = new ProviderStartupDeadline(
|
||||
this.#options.startupTimeoutMs,
|
||||
@@ -343,7 +354,7 @@ export class HopperClient {
|
||||
return err(new HopperStartError({ cause }));
|
||||
}
|
||||
if (deadline.signal.aborted) {
|
||||
return this.#startupFailure(startupInterruption(deadline));
|
||||
return this.#startupFailure(await this.#startupInterruption(deadline));
|
||||
}
|
||||
const socketPath = join(this.#resources.runtimeRoot.path, "bridge.sock");
|
||||
this.#token = randomBytes(32).toString("hex");
|
||||
@@ -351,6 +362,7 @@ export class HopperClient {
|
||||
this.#launcherExitCode = undefined;
|
||||
this.#launcherFailureDiagnostic = undefined;
|
||||
const runId = this.#options.runId ?? randomUUID();
|
||||
this.#runId = runId;
|
||||
const launched: Result<
|
||||
BridgeLaunch,
|
||||
HopperStartError | HopperCancelledError | HopperProcessError
|
||||
@@ -368,7 +380,7 @@ export class HopperClient {
|
||||
if (!launched.ok) {
|
||||
return this.#startupFailure(
|
||||
deadline.signal.aborted
|
||||
? startupInterruption(deadline)
|
||||
? await this.#startupInterruption(deadline)
|
||||
: launched.error,
|
||||
);
|
||||
}
|
||||
@@ -399,9 +411,7 @@ export class HopperClient {
|
||||
}
|
||||
const remainingMs = deadline.remainingMs();
|
||||
if (remainingMs <= 0) {
|
||||
return this.#startupFailure(
|
||||
new HopperTimeoutError(this.#options.startupTimeoutMs),
|
||||
);
|
||||
return this.#startupFailure(await this.#startupInterruption(deadline));
|
||||
}
|
||||
const health = await this.#request(
|
||||
"health",
|
||||
@@ -413,7 +423,9 @@ export class HopperClient {
|
||||
);
|
||||
if (!health.ok) {
|
||||
return this.#startupFailure(
|
||||
deadline.signal.aborted ? startupInterruption(deadline) : health.error,
|
||||
deadline.signal.aborted
|
||||
? await this.#startupInterruption(deadline)
|
||||
: health.error,
|
||||
);
|
||||
}
|
||||
const parsed = parseHopperServerInfo(health.value, runId);
|
||||
@@ -426,22 +438,20 @@ export class HopperClient {
|
||||
primary: AnalysisError,
|
||||
): Promise<Result<never, AnalysisError>> {
|
||||
this.#rememberStartupFailure(primary);
|
||||
// Failed readiness never authorized this connection to mutate a document.
|
||||
this.#releaseTransport();
|
||||
const closed = await this.#cleanup();
|
||||
return err(
|
||||
closed.ok
|
||||
? primary
|
||||
: analysisErrorWithCleanupFailure(primary, closed.error, "open_binary"),
|
||||
);
|
||||
const failure = closed.ok
|
||||
? primary
|
||||
: analysisErrorWithCleanupFailure(primary, closed.error, "open_binary");
|
||||
this.#startupFailureResult = failure;
|
||||
return err(failure);
|
||||
}
|
||||
|
||||
#rememberStartupFailure(failure: AnalysisError): void {
|
||||
if (failure instanceof HopperProcessError)
|
||||
this.#rememberProcessFailure(failure, []);
|
||||
else if (
|
||||
this.#operationFailure === undefined &&
|
||||
(failure instanceof HopperTimeoutError ||
|
||||
failure instanceof HopperStartError)
|
||||
)
|
||||
else if (this.#operationFailure === undefined)
|
||||
this.#operationFailure = {
|
||||
state:
|
||||
failure instanceof HopperTimeoutError ? "not_started" : "unknown",
|
||||
@@ -500,6 +510,7 @@ export class HopperClient {
|
||||
// best-effort cleanup: a rejected startup must not mask close/cleanup;
|
||||
// #cleanup reports the authoritative close result.
|
||||
await starting?.catch(() => undefined);
|
||||
await this.#reconnectForCleanup();
|
||||
return await this.#cleanup(options);
|
||||
} finally {
|
||||
if (this.#startPromise === starting) this.#startPromise = undefined;
|
||||
@@ -510,6 +521,51 @@ export class HopperClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reauthenticate this run after late readiness or a lost transport. */
|
||||
async #reconnectForCleanup(): Promise<void> {
|
||||
if (this.#socket?.destroyed === true) this.#releaseTransport();
|
||||
const root = this.#resources.runtimeRoot;
|
||||
const runId = this.#runId;
|
||||
if (
|
||||
this.#socket !== undefined ||
|
||||
root === undefined ||
|
||||
runId === undefined ||
|
||||
this.#token === undefined ||
|
||||
this.#resources.shutdownConfirmed ||
|
||||
this.#resources.launch?.providerLifetime !== "external-application"
|
||||
)
|
||||
return;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
HOPPER_SHUTDOWN_TIMEOUT_MS,
|
||||
);
|
||||
this.#closing = true;
|
||||
try {
|
||||
const connected = await connectHopperSocketOnce(
|
||||
join(root.path, "bridge.sock"),
|
||||
controller.signal,
|
||||
);
|
||||
if (!connected.ok) return;
|
||||
const socket = connected.value;
|
||||
this.#socket = socket;
|
||||
this.#attachSocket(socket);
|
||||
const health = await this.#request(
|
||||
"health",
|
||||
{},
|
||||
{
|
||||
signal: controller.signal,
|
||||
timeoutMs: HOPPER_SHUTDOWN_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
if (health.ok && parseHopperServerInfo(health.value, runId).ok) return;
|
||||
this.#releaseTransport();
|
||||
} finally {
|
||||
this.#closing = false;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async #cleanup(
|
||||
options: HopperClientCloseOptions = {},
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
@@ -523,18 +579,18 @@ export class HopperClient {
|
||||
logger: this.#logger,
|
||||
onDiagnostic: this.#options.onDiagnostic,
|
||||
request: (method) =>
|
||||
this.#request(method, {}, { timeoutMs: SHUTDOWN_TIMEOUT_MS }),
|
||||
this.#request(method, {}, { timeoutMs: HOPPER_SHUTDOWN_TIMEOUT_MS }),
|
||||
retainDocument: options.retainDocument === true,
|
||||
releaseTransport: (socket) => {
|
||||
this.#failAll(new HopperProcessError(null));
|
||||
if (socket !== undefined) this.#detachSocket(socket);
|
||||
socket?.destroy();
|
||||
},
|
||||
releaseTransport: () => this.#releaseTransport(),
|
||||
});
|
||||
} finally {
|
||||
if (this.#socket?.destroyed === true) this.#socket = undefined;
|
||||
|
||||
if (this.#socket === undefined) this.#token = undefined;
|
||||
// Retained external launches may publish their authenticated socket later.
|
||||
if (this.#resources.runtimeRoot === undefined) {
|
||||
this.#token = undefined;
|
||||
this.#runId = undefined;
|
||||
}
|
||||
if (this.#socket === undefined) this.#responses.reset();
|
||||
if (this.#resources.processSupervisor === undefined) {
|
||||
this.#launcherExitCode = undefined;
|
||||
@@ -662,6 +718,17 @@ export class HopperClient {
|
||||
"launch",
|
||||
launcherFailure,
|
||||
);
|
||||
if (
|
||||
this.#resources.launch?.authorizationDenialBeforeHandoff === true &&
|
||||
nativeHopperLaunchWasDenied(launcherFailure)
|
||||
) {
|
||||
this.#resources.shutdownConfirmed = true;
|
||||
return new HopperStartError({
|
||||
userMessage:
|
||||
"macOS denied permission to send Apple Events to Hopper. Allow the application that starts REA under System Settings > Privacy & Security > Automation, then retry opening the target.",
|
||||
launcherFailure,
|
||||
});
|
||||
}
|
||||
return new HopperStartError({
|
||||
userMessage:
|
||||
"Hopper's launcher helper failed before bridge readiness. Review details.launcher and complete Hopper setup before retrying.",
|
||||
@@ -757,6 +824,15 @@ export class HopperClient {
|
||||
socket.off("close", this.#onSocketClose);
|
||||
}
|
||||
|
||||
#releaseTransport(): void {
|
||||
const socket = this.#socket;
|
||||
this.#socket = undefined;
|
||||
this.#failAll(new HopperProcessError(null));
|
||||
if (socket !== undefined) this.#detachSocket(socket);
|
||||
socket?.destroy();
|
||||
this.#responses.reset();
|
||||
}
|
||||
|
||||
#attachLauncher(launch: BridgeLaunch): void {
|
||||
this.#resources.processSupervisor = new ProviderProcessSupervisor(launch, {
|
||||
maxDiagnosticBytes: HOPPER_PROCESS_DIAGNOSTIC_BYTES,
|
||||
@@ -927,10 +1003,3 @@ const mergeFailedRequests = (
|
||||
};
|
||||
|
||||
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
|
||||
|
||||
const startupInterruption = (
|
||||
deadline: ProviderStartupDeadline,
|
||||
): HopperCancelledError | HopperTimeoutError =>
|
||||
deadline.interruption === "cancelled"
|
||||
? new HopperCancelledError()
|
||||
: new HopperTimeoutError(deadline.timeoutMs);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import type { HopperLauncherOutcome } from "../domain/hopperErrors.js";
|
||||
import { nativeHopperLaunchWasDenied } from "./HopperProcessDiagnostic.js";
|
||||
|
||||
// Captured from Hopper 6.1.0-demo and 6.6.0-demo's unmodified helpers with native
|
||||
// AppleScript error -1743 before open dispatch. The target script never ran.
|
||||
const denied: HopperLauncherOutcome = {
|
||||
exit_code: 2,
|
||||
signal: null,
|
||||
stdout: { text: "", bytes: 0, retained_bytes: 0 },
|
||||
stderr: {
|
||||
text: "Cannot launch Hopper!\nNot authorized to send Apple events to current application.\n",
|
||||
bytes: 82,
|
||||
retained_bytes: 82,
|
||||
},
|
||||
output_closed: true,
|
||||
diagnostic_truncated: false,
|
||||
};
|
||||
|
||||
it("recognizes the native helper's complete authorization rejection", () => {
|
||||
expect(nativeHopperLaunchWasDenied(denied)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ output_closed: false },
|
||||
{ diagnostic_truncated: true },
|
||||
{ signal: "SIGTERM", exit_code: null },
|
||||
{ signal: "SIGTERM", exit_code: 2 },
|
||||
{ exit_code: 0 },
|
||||
{
|
||||
stderr: {
|
||||
...denied.stderr,
|
||||
text: "Cannot launch Hopper!\nExpected end of line, etc. but found identifier.\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
stderr: {
|
||||
...denied.stderr,
|
||||
text: `${denied.stderr.text}another failure\n`,
|
||||
},
|
||||
},
|
||||
{
|
||||
stderr: {
|
||||
...denied.stderr,
|
||||
text: "Not authorized to send Apple events to current application.\n",
|
||||
},
|
||||
},
|
||||
])(
|
||||
"preserves unknown document ownership for incomplete or different failures: %j",
|
||||
(change) => {
|
||||
expect(nativeHopperLaunchWasDenied({ ...denied, ...change })).toBe(false);
|
||||
},
|
||||
);
|
||||
@@ -4,6 +4,25 @@ import {
|
||||
} from "../domain/hopperStartupFailure.js";
|
||||
import type { ProviderProcessDiagnostic } from "../process/ProviderProcess.js";
|
||||
import { parseLinuxPrivateDisplayDiagnostic } from "./LinuxPrivateDisplayDiagnostic.js";
|
||||
import type { HopperLauncherOutcome } from "../domain/hopperErrors.js";
|
||||
|
||||
/**
|
||||
* Hopper's native helper sends activate before its open-document Apple event.
|
||||
* Recognize its explicit authorization rejection. The launcher must separately
|
||||
* establish that caller arguments could not alter the generated AppleScript.
|
||||
* Other script errors, signals, and incomplete output leave ownership unknown.
|
||||
*/
|
||||
export const nativeHopperLaunchWasDenied = (
|
||||
outcome: HopperLauncherOutcome | undefined,
|
||||
): outcome is HopperLauncherOutcome =>
|
||||
outcome !== undefined &&
|
||||
outcome.exit_code === 2 &&
|
||||
outcome.signal === null &&
|
||||
outcome.output_closed &&
|
||||
!outcome.diagnostic_truncated &&
|
||||
/^Cannot launch Hopper!\r?\nNot authorized to send Apple events to [^\r\n]+\.\r?\n?$/u.test(
|
||||
outcome.stderr.text,
|
||||
);
|
||||
|
||||
/** Extract only a matching Linux adapter failure record. */
|
||||
export const hopperLauncherFailureDiagnostic = (
|
||||
|
||||
@@ -237,6 +237,7 @@ export class HopperProvider implements AnalysisProviderCandidate {
|
||||
: { launchMode: "native" as const }),
|
||||
}),
|
||||
...(context === undefined ? {} : { runId: context.runId }),
|
||||
startupTimeoutMs: this.config.hopperStartupTimeoutMs,
|
||||
logger: this.logger.child({ layer: "bridge" }),
|
||||
onDiagnostic: (diagnostic) =>
|
||||
this.logger.info(diagnostic, "Hopper bridge reported a diagnostic"),
|
||||
|
||||
@@ -2,6 +2,9 @@ import { HopperProtocolError } from "../domain/hopperErrors.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Bound cleanup handshakes and the bridge's idle reconnection wait. */
|
||||
export const HOPPER_SHUTDOWN_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Authenticated identity returned by the Hopper bridge health handshake. */
|
||||
export interface HopperServerInfo {
|
||||
readonly name: string;
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { access, copyFile, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { expect, onTestFinished, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
|
||||
import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js";
|
||||
|
||||
const statusSchema = z.object({ result: z.object({ open: z.boolean() }) });
|
||||
const test = workspaceCliTest.skipIf(process.platform !== "darwin");
|
||||
|
||||
test("retains a denial-shaped failure when caller arguments can alter the helper's AppleScript", async ({
|
||||
workspace,
|
||||
}) => {
|
||||
for (const source of ["target", "loader"] as const) {
|
||||
const target = workspace.path(
|
||||
source === "target" ? 'quoted"target' : "loader-target",
|
||||
);
|
||||
await copyFile("/usr/bin/true", target);
|
||||
const mode = await workspace.write(
|
||||
`${source}-mode`,
|
||||
"denied-after-handoff",
|
||||
);
|
||||
const receipt = workspace.path(`${source}-bootstrap`);
|
||||
const loaderArgs = [
|
||||
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
|
||||
mode,
|
||||
receipt,
|
||||
...(source === "loader" ? ['caller"loader'] : []),
|
||||
];
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [resolve("dist/main.js")],
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...workspace.environment,
|
||||
PATH: process.env.PATH ?? "",
|
||||
REA_ANALYSIS_PROVIDER: "hopper",
|
||||
HOPPER_LAUNCHER_PATH: process.execPath,
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify(loaderArgs),
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
});
|
||||
const mcp = new Client({ name: "hopper-literal-boundary", version: "1" });
|
||||
let runtime: string | undefined;
|
||||
onTestFinished(async () => {
|
||||
await mcp.close();
|
||||
await transport.close();
|
||||
// The fixture created only this marker, never a native GUI document.
|
||||
if (runtime !== undefined)
|
||||
await rm(runtime, { recursive: true, force: true });
|
||||
const acquired = await acquireHopperTargetLease({
|
||||
targetPath: target,
|
||||
targetKind: "executable",
|
||||
loaderArgs,
|
||||
runId: "literal-boundary-test-cleanup",
|
||||
});
|
||||
if (!acquired.acquired) throw new Error("Fixture lease remained live");
|
||||
await acquired.lease.release();
|
||||
});
|
||||
await mcp.connect(transport);
|
||||
const call = (name: string, arguments_: Record<string, string> = {}) =>
|
||||
mcp.callTool({ name, arguments: arguments_ });
|
||||
expect((await call("open_binary", { path: target })).isError).not.toBe(
|
||||
true,
|
||||
);
|
||||
const first = parseMcpToolError(await call("current_document"));
|
||||
runtime = dirname(await readFile(receipt, "utf8"));
|
||||
expect(first.error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
diagnostics: {
|
||||
primary_error: {
|
||||
code: "provider_unavailable",
|
||||
details: {
|
||||
launcher: {
|
||||
exit_code: 2,
|
||||
stderr: {
|
||||
text: expect.stringContaining(
|
||||
"Not authorized to send Apple events",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await access(`${receipt}.document-created`);
|
||||
await access(runtime);
|
||||
expect(parseMcpToolError(await call("list_procedures"))).toEqual(first);
|
||||
expect(parseMcpToolError(await call("close_binary")).error.code).toBe(
|
||||
"cleanup_incomplete",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("recovers a denied Hopper launch through the same stdio MCP session and CLI", async ({
|
||||
workspace,
|
||||
cli,
|
||||
}) => {
|
||||
const target = workspace.path("target");
|
||||
await copyFile("/usr/bin/true", target);
|
||||
const mode = await workspace.write("mode", "denied");
|
||||
const receipt = workspace.path("bootstrap-path");
|
||||
const environment = {
|
||||
...workspace.environment,
|
||||
PATH: process.env.PATH ?? "",
|
||||
REA_ANALYSIS_PROVIDER: "hopper",
|
||||
HOPPER_LAUNCHER_PATH: process.execPath,
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
|
||||
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
|
||||
mode,
|
||||
receipt,
|
||||
]),
|
||||
REA_LOG_LEVEL: "silent",
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [resolve("dist/main.js")],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const mcp = new Client({ name: "hopper-startup-recovery", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await mcp.close();
|
||||
await transport.close();
|
||||
});
|
||||
await mcp.connect(transport);
|
||||
const call = (name: string, arguments_: Record<string, string> = {}) =>
|
||||
mcp.callTool({ name, arguments: arguments_ }).catch((cause: unknown) => {
|
||||
throw new Error(`${name}: ${stderr}`, { cause });
|
||||
});
|
||||
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
|
||||
for (const operation of ["current_document", "list_procedures"]) {
|
||||
const { error } = parseMcpToolError(await call(operation));
|
||||
expect(error).toMatchObject({
|
||||
code: "provider_unavailable",
|
||||
details: {
|
||||
stage: "launch",
|
||||
launcher: {
|
||||
exit_code: 2,
|
||||
stderr: {
|
||||
text: expect.stringContaining(
|
||||
"Not authorized to send Apple events",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(error.message).toContain("Automation");
|
||||
const bootstrap = await readFile(receipt, "utf8");
|
||||
await expect(access(dirname(bootstrap))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
expect((await call("close_binary")).isError).not.toBe(true);
|
||||
const deniedCli = await cli.run({
|
||||
arguments: ["analyze", target, "--provider", "hopper", "--json"],
|
||||
environment,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
expect(deniedCli.exitCode).toBe(1);
|
||||
expect(deniedCli.json).toMatchObject({
|
||||
code: "provider_unavailable",
|
||||
details: { launcher: { exit_code: 2 } },
|
||||
});
|
||||
|
||||
await writeFile(mode, "bridge");
|
||||
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
|
||||
expect((await call("current_document")).isError).not.toBe(true);
|
||||
expect((await call("close_binary")).isError).not.toBe(true);
|
||||
expect(
|
||||
statusSchema.parse((await call("binary_session")).structuredContent).result
|
||||
.open,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves a timed-out handoff and closes only a late bridge with matching run identity", async ({
|
||||
workspace,
|
||||
}) => {
|
||||
const target = workspace.path("target");
|
||||
await copyFile("/usr/bin/true", target);
|
||||
const mode = await workspace.write("mode", "handoff");
|
||||
const receipt = workspace.path("bootstrap-path");
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [resolve("dist/main.js")],
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...workspace.environment,
|
||||
PATH: process.env.PATH ?? "",
|
||||
REA_ANALYSIS_PROVIDER: "hopper",
|
||||
HOPPER_LAUNCHER_PATH: process.execPath,
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
|
||||
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
|
||||
mode,
|
||||
receipt,
|
||||
]),
|
||||
REA_HOPPER_STARTUP_TIMEOUT_MS: "2000",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
});
|
||||
const mcp = new Client({ name: "hopper-late-readiness", version: "1" });
|
||||
const children: ChildProcess[] = [];
|
||||
let runtime: string | undefined;
|
||||
const stop = async (child: ChildProcess) => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = new Promise<void>((resolveExit) =>
|
||||
child.once("exit", () => resolveExit()),
|
||||
);
|
||||
child.kill("SIGKILL");
|
||||
await exited;
|
||||
};
|
||||
onTestFinished(async () => {
|
||||
await Promise.all(children.map(stop));
|
||||
await mcp.close();
|
||||
await transport.close();
|
||||
if (runtime !== undefined)
|
||||
await rm(runtime, { recursive: true, force: true });
|
||||
});
|
||||
await mcp.connect(transport);
|
||||
const call = (name: string, arguments_: Record<string, string> = {}) =>
|
||||
mcp.callTool({ name, arguments: arguments_ });
|
||||
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
|
||||
const initial = parseMcpToolError(await call("current_document"));
|
||||
expect(initial.error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
diagnostics: {
|
||||
primary_error: {
|
||||
code: "provider_timeout",
|
||||
details: { timeout_ms: 2000, launcher: { exit_code: 0 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parseMcpToolError(await call("list_procedures"))).toEqual(initial);
|
||||
const status = z.object({
|
||||
result: z.object({
|
||||
provider_operation_health: z.object({
|
||||
state: z.literal("not_started"),
|
||||
stage: z.literal("launch"),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
status.parse((await call("binary_session")).structuredContent);
|
||||
expect(parseMcpToolError(await call("close_binary")).error.code).toBe(
|
||||
"cleanup_incomplete",
|
||||
);
|
||||
const bootstrap = await readFile(await readFile(receipt, "utf8"), "utf8");
|
||||
const setting = (name: string): string => {
|
||||
const line = bootstrap
|
||||
.split("\n")
|
||||
.find((value) => value.startsWith(`${name} = `));
|
||||
if (line === undefined)
|
||||
throw new Error(`Missing bootstrap setting ${name}`);
|
||||
return z.string().parse(JSON.parse(line.slice(name.length + 3)));
|
||||
};
|
||||
const socket = setting("REA_SOCKET");
|
||||
const token = setting("REA_TOKEN");
|
||||
const runId = setting("REA_RUN_ID");
|
||||
runtime = dirname(socket);
|
||||
const lateBridge = async (identity: string, credential = token) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[resolve("tests/fixtures/fakeHopper.mjs"), socket, credential, identity],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
children.push(child);
|
||||
await vi.waitFor(() => access(socket));
|
||||
return child;
|
||||
};
|
||||
for (const [identity, credential] of [
|
||||
["different-run", token],
|
||||
[runId, "different-token"],
|
||||
] as const) {
|
||||
const wrong = await lateBridge(identity, credential);
|
||||
expect(parseMcpToolError(await call("close_binary")).error.code).toBe(
|
||||
"cleanup_incomplete",
|
||||
);
|
||||
expect(wrong.exitCode).toBeNull();
|
||||
await access(runtime);
|
||||
await stop(wrong);
|
||||
await rm(socket, { force: true });
|
||||
}
|
||||
const owned = await lateBridge(runId);
|
||||
expect((await call("close_binary")).isError).not.toBe(true);
|
||||
await vi.waitFor(() => expect(owned.exitCode).toBe(0));
|
||||
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await writeFile(mode, "bridge");
|
||||
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
|
||||
expect((await call("current_document")).isError).not.toBe(true);
|
||||
expect((await call("close_binary")).isError).not.toBe(true);
|
||||
});
|
||||
@@ -1,16 +1,14 @@
|
||||
import { chmod, copyFile, mkdir, readdir, rm, rmdir } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { chmod, copyFile, rm } from "node:fs/promises";
|
||||
|
||||
import { describe, expect, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
|
||||
|
||||
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../../../src/hopper/HopperTargetLease.js";
|
||||
import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js";
|
||||
|
||||
const FAILED_LAUNCHER = `#!/bin/sh
|
||||
printf 'Cannot launch Hopper!\\nNot authorized to send Apple events to Hopper Disassembler.\\n' >&2
|
||||
printf 'Cannot launch Hopper!\\nDocument state is unknown.\\n' >&2
|
||||
exit 2
|
||||
`;
|
||||
|
||||
@@ -27,41 +25,6 @@ const RetainedRuntime = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const listLeases = async (): Promise<ReadonlySet<string>> =>
|
||||
new Set(await readdir(HOPPER_TARGET_LEASE_DIRECTORY).catch(() => []));
|
||||
|
||||
const refusesConnections = (socketPath: string): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
const socket = createConnection(socketPath);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.once("error", (error: NodeJS.ErrnoException) =>
|
||||
resolve(error.code === "ECONNREFUSED"),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove a lease socket only when its owner has exited, holding the same
|
||||
* recovery reservation as production so a concurrent acquirer is never
|
||||
* unlinked. Live or busy leases are left for their owners.
|
||||
*/
|
||||
const removeStaleLease = async (socketPath: string): Promise<void> => {
|
||||
const recoveryPath = `${socketPath}.recovery`;
|
||||
try {
|
||||
await mkdir(recoveryPath, { mode: 0o700 });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (await refusesConnections(socketPath))
|
||||
await rm(socketPath, { force: true });
|
||||
} finally {
|
||||
await rmdir(recoveryPath);
|
||||
}
|
||||
};
|
||||
|
||||
describe("Hopper launcher failure through the one-shot CLI", () => {
|
||||
// Hopper target leases are acquired only for the macOS launcher route.
|
||||
workspaceCliTest.skipIf(process.platform !== "darwin")(
|
||||
@@ -74,11 +37,16 @@ describe("Hopper launcher failure through the one-shot CLI", () => {
|
||||
await chmod(launcherPath, 0o700);
|
||||
const targetPath = workspace.path("probe");
|
||||
await copyFile("/usr/bin/true", targetPath);
|
||||
const leasesBefore = await listLeases();
|
||||
const loaderArgs = ["--probe"];
|
||||
onTestFinished(async () => {
|
||||
for (const name of await listLeases())
|
||||
if (!leasesBefore.has(name) && name.endsWith(".sock"))
|
||||
await removeStaleLease(join(HOPPER_TARGET_LEASE_DIRECTORY, name));
|
||||
const recovered = await acquireHopperTargetLease({
|
||||
targetPath,
|
||||
targetKind: "executable",
|
||||
loaderArgs,
|
||||
runId: "failed-cli-test-cleanup",
|
||||
});
|
||||
expect(recovered.acquired).toBe(true);
|
||||
if (recovered.acquired) await recovered.lease.release();
|
||||
});
|
||||
|
||||
for (const attempt of ["first", "retry"]) {
|
||||
@@ -87,6 +55,7 @@ describe("Hopper launcher failure through the one-shot CLI", () => {
|
||||
environment: {
|
||||
...workspace.environment,
|
||||
HOPPER_LAUNCHER_PATH: launcherPath,
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify(loaderArgs),
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
timeoutMs: 30_000,
|
||||
@@ -113,7 +82,7 @@ describe("Hopper launcher failure through the one-shot CLI", () => {
|
||||
exit_code: 2,
|
||||
stderr: {
|
||||
text: expect.stringContaining(
|
||||
"Not authorized to send Apple events",
|
||||
"Document state is unknown",
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"the shipped bridge reauthenticates reconnects and releases its execution thread after expiry",
|
||||
async () => {
|
||||
const runtime = await PrivateRuntimeRoot.create({ parent: "/tmp" });
|
||||
onTestFinished(() => runtime.close());
|
||||
const { stdout } = await execute("python3", [
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../fixtures/hopperBridgeReconnectProbe.py",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
fileURLToPath(
|
||||
new URL("../../../../bridge/hopper_bridge.py", import.meta.url),
|
||||
),
|
||||
runtime.path,
|
||||
]);
|
||||
const rows = z
|
||||
.array(
|
||||
z.object({
|
||||
mode: z.string(),
|
||||
requests: z.array(z.string()),
|
||||
listener_removed: z.literal(true),
|
||||
thread_released: z.literal(true),
|
||||
}),
|
||||
)
|
||||
.parse(JSON.parse(stdout));
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
mode: "unauthorized",
|
||||
requests: ["health", "shutdown_document", "shutdown_document"],
|
||||
listener_removed: true,
|
||||
thread_released: true,
|
||||
},
|
||||
...["disconnect", "lost-reply"].map((mode) => ({
|
||||
mode,
|
||||
requests: [
|
||||
"health",
|
||||
"health",
|
||||
"shutdown_document",
|
||||
"shutdown_document",
|
||||
],
|
||||
listener_removed: true,
|
||||
thread_released: true,
|
||||
})),
|
||||
{
|
||||
mode: "expiry",
|
||||
requests: ["health"],
|
||||
listener_removed: true,
|
||||
thread_released: true,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -210,7 +210,15 @@ it("blocks a fresh launch while cleanup remains unverified", async () => {
|
||||
const { client, child } = await failedStartup(launcher);
|
||||
await expect(client.start()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "HopperProtocolError" },
|
||||
error: {
|
||||
cleanupIncomplete: true,
|
||||
cause: { _tag: "HopperTimeoutError" },
|
||||
diagnostics: { primary_error: { code: "provider_timeout" } },
|
||||
},
|
||||
});
|
||||
expect(client.operationHealth()).toMatchObject({
|
||||
state: "not_started",
|
||||
stage: "launch",
|
||||
});
|
||||
expect(launcher.processes).toEqual([child]);
|
||||
launcher.allowCleanup = true;
|
||||
@@ -309,7 +317,10 @@ it("retries a failed lease release after document, process, and runtime cleanup
|
||||
});
|
||||
await expect(client.start()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "HopperProtocolError" },
|
||||
error: {
|
||||
cleanupIncomplete: true,
|
||||
diagnostics: { primary_error: { code: "provider_timeout" } },
|
||||
},
|
||||
});
|
||||
releaseAllowed = true;
|
||||
await expect(client.close()).resolves.toEqual({ ok: true, value: null });
|
||||
|
||||
@@ -35,6 +35,7 @@ export class HopperFixtureLauncher implements BridgeLauncher {
|
||||
constructor(
|
||||
readonly tokenOverride?: string,
|
||||
readonly diagnosticMode: "quiet" | "noisy" | "noisy_no_socket" = "quiet",
|
||||
readonly healthMode: "ready" | "gated" = "ready",
|
||||
) {}
|
||||
|
||||
launch(session: BridgeSession) {
|
||||
@@ -51,6 +52,7 @@ export class HopperFixtureLauncher implements BridgeLauncher {
|
||||
session.runId,
|
||||
"acknowledge",
|
||||
this.diagnosticMode,
|
||||
this.healthMode,
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { access, rm } from "node:fs/promises";
|
||||
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { ok } from "../../../../src/domain/result.js";
|
||||
import type {
|
||||
BridgeLauncher,
|
||||
BridgeSession,
|
||||
} from "../../../../src/hopper/BridgeLauncher.js";
|
||||
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
|
||||
import { HopperFixtureLauncher } from "./hopperClient.fixture.js";
|
||||
|
||||
const stop = async (child: ChildProcess): Promise<void> => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = new Promise<void>((resolve) =>
|
||||
child.once("exit", () => resolve()),
|
||||
);
|
||||
child.kill("SIGKILL");
|
||||
await exited;
|
||||
};
|
||||
|
||||
// The GUI is a sibling owned by the test, never a descendant owned by REA's helper.
|
||||
class ExternalBridgeLauncher implements BridgeLauncher {
|
||||
readonly fixture: HopperFixtureLauncher;
|
||||
readonly sessions: BridgeSession[] = [];
|
||||
readonly helpers: ChildProcess[] = [];
|
||||
|
||||
constructor(
|
||||
readonly identity: "matching" | "wrong-run" | "wrong-token" = "matching",
|
||||
healthMode: "ready" | "gated" = "ready",
|
||||
) {
|
||||
this.fixture = new HopperFixtureLauncher(
|
||||
identity === "wrong-token" ? "foreign-token" : undefined,
|
||||
"quiet",
|
||||
healthMode,
|
||||
);
|
||||
}
|
||||
|
||||
async launch(session: BridgeSession) {
|
||||
this.sessions.push(session);
|
||||
await this.fixture.launch({
|
||||
...session,
|
||||
runId: this.identity === "wrong-run" ? "foreign-run" : session.runId,
|
||||
});
|
||||
const helper = spawn(process.execPath, ["-e", "process.exit(0)"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
this.helpers.push(helper);
|
||||
return ok({
|
||||
process: helper,
|
||||
ownsProcessLifetime: false as const,
|
||||
providerLifetime: "external-application" as const,
|
||||
shutdownMode: "bridge-request" as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const setup = (
|
||||
identity?: "matching" | "wrong-run" | "wrong-token",
|
||||
healthMode?: "ready" | "gated",
|
||||
) => {
|
||||
const launcher = new ExternalBridgeLauncher(identity, healthMode);
|
||||
const client = new HopperClient({ launcher, startupTimeoutMs: 2_000 });
|
||||
onTestFinished(async () => {
|
||||
await Promise.all(
|
||||
[...launcher.helpers, ...launcher.fixture.processes].map(stop),
|
||||
);
|
||||
await client.close();
|
||||
await Promise.all(
|
||||
launcher.sessions.map(({ directory }) =>
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
return { client, launcher };
|
||||
};
|
||||
|
||||
it.each(["wrong-run", "wrong-token"] as const)(
|
||||
"never requests document shutdown after the first handshake rejects %s",
|
||||
async (identity) => {
|
||||
const { client, launcher } = setup(identity);
|
||||
const started = await client.start();
|
||||
if (started.ok) throw new Error("Expected a rejected handshake");
|
||||
expect(projectAnalysisError(started.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
});
|
||||
await launcher.fixture.waitForRequest("health");
|
||||
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
|
||||
"health",
|
||||
]);
|
||||
expect(client.operationHealth()).toMatchObject({
|
||||
state: "unknown",
|
||||
stage: "launch",
|
||||
});
|
||||
const closed = await client.close();
|
||||
if (closed.ok)
|
||||
throw new Error("Expected retained foreign bridge ownership");
|
||||
expect(projectAnalysisError(closed.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
});
|
||||
expect(
|
||||
launcher.fixture.requests.every(({ method }) => method === "health"),
|
||||
).toBe(true);
|
||||
expect(launcher.fixture.processes[0]?.exitCode).toBeNull();
|
||||
const session = launcher.sessions[0];
|
||||
if (session === undefined) throw new Error("Fixture did not launch");
|
||||
await access(session.directory);
|
||||
},
|
||||
);
|
||||
|
||||
it("reconnects for authenticated cleanup after a previously valid bridge transport fails", async () => {
|
||||
const { client, launcher } = setup();
|
||||
const started = await client.start();
|
||||
if (!started.ok) throw started.error;
|
||||
const malformed = await client.callTool("malformed");
|
||||
expect(malformed).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "HopperProtocolError" },
|
||||
});
|
||||
const closed = await client.close();
|
||||
if (!closed.ok) throw closed.error;
|
||||
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
|
||||
"health",
|
||||
"malformed",
|
||||
"health",
|
||||
"shutdown_document",
|
||||
]);
|
||||
await vi.waitFor(() =>
|
||||
expect(launcher.fixture.processes[0]?.exitCode).toBe(0),
|
||||
);
|
||||
const session = launcher.sessions[0];
|
||||
if (session === undefined) throw new Error("Fixture did not launch");
|
||||
await expect(access(session.directory)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves launcher diagnostics after a health deadline and reauthenticates for cleanup", async () => {
|
||||
const { client, launcher } = setup("matching", "gated");
|
||||
const starting = client.start();
|
||||
const first = await launcher.fixture.waitForRequest("health");
|
||||
const started = await starting;
|
||||
if (started.ok) throw new Error("Expected a health deadline");
|
||||
expect(projectAnalysisError(started.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
diagnostics: {
|
||||
primary_error: {
|
||||
code: "provider_timeout",
|
||||
details: {
|
||||
timeout_ms: 2_000,
|
||||
stage: "startup",
|
||||
launcher: { exit_code: 0, output_closed: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(client.requestActivity()).toBeNull();
|
||||
const closing = client.close();
|
||||
const next = await vi.waitFor(() => {
|
||||
const request = launcher.fixture.requests.find(
|
||||
({ method, id }) => method === "health" && id !== first.id,
|
||||
);
|
||||
if (request === undefined)
|
||||
throw new Error("Waiting for recovery handshake");
|
||||
return request;
|
||||
});
|
||||
await launcher.fixture.release(next);
|
||||
const closed = await closing;
|
||||
if (!closed.ok) throw closed.error;
|
||||
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
|
||||
"health",
|
||||
"health",
|
||||
"shutdown_document",
|
||||
]);
|
||||
});
|
||||
Vendored
+15
-10
@@ -6,6 +6,7 @@ const [
|
||||
runId,
|
||||
shutdownMode = "acknowledge",
|
||||
diagnosticMode = "quiet",
|
||||
healthMode = "ready",
|
||||
] = process.argv.slice(2);
|
||||
let diagnosticOutputEmitted = false;
|
||||
const heldReplies = new Map();
|
||||
@@ -201,17 +202,21 @@ const server = createServer((socket) => {
|
||||
send({ id: request.id, error: { code: -32001, message: "bad token" } });
|
||||
} else if (request.method === "health") {
|
||||
emitDiagnosticOutput();
|
||||
send(
|
||||
{
|
||||
id: request.id,
|
||||
result: {
|
||||
name: "REA Hopper bridge",
|
||||
version: "1.0.0",
|
||||
run_id: runId,
|
||||
const reply = () =>
|
||||
send(
|
||||
{
|
||||
id: request.id,
|
||||
result: {
|
||||
name: "REA Hopper bridge",
|
||||
version: "1.0.0",
|
||||
run_id: runId,
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
true,
|
||||
);
|
||||
if (healthMode === "gated") heldReplies.set(request.id, reply);
|
||||
else if (healthMode === "ready") reply();
|
||||
else throw new Error(`Unknown health fixture mode: ${healthMode}`);
|
||||
} else if (
|
||||
request.method === "shutdown" ||
|
||||
request.method === "shutdown_document"
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
"""Exercise the shipped socket lifecycle with a recording native dispatch port."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from hopperBridgeFacadeProbe import load_bridge
|
||||
|
||||
|
||||
def probe(path, socket_path, mode):
|
||||
bridge = load_bridge(path)
|
||||
bridge.update(REA_SOCKET=socket_path, REA_TOKEN="owned-token", REA_RUN_ID="owned-run",
|
||||
REA_RECONNECT_TIMEOUT_SECONDS=0.2)
|
||||
requests = []
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
health = bridge["_dispatch"]
|
||||
|
||||
def dispatch(method, params):
|
||||
requests.append(method)
|
||||
if method == "health":
|
||||
if mode == "lost-reply" and requests == ["health"]:
|
||||
entered.set()
|
||||
assert release.wait(2), "Probe never released the blocked reply"
|
||||
return health(method, params)
|
||||
assert method == "shutdown_document", method
|
||||
closed = not params.get("unconfirmed", False)
|
||||
return {"shutdown": True, "analysis_stopped": True, "document_closed": closed}
|
||||
|
||||
bridge["_dispatch"] = dispatch
|
||||
failures = []
|
||||
|
||||
def serve():
|
||||
try:
|
||||
bridge["_run"]()
|
||||
except Exception as error:
|
||||
failures.append(repr(error))
|
||||
|
||||
worker = threading.Thread(target=serve, daemon=True)
|
||||
worker.start()
|
||||
deadline = time.monotonic() + 2
|
||||
while not Path(socket_path).exists():
|
||||
assert time.monotonic() < deadline, failures
|
||||
time.sleep(0.005)
|
||||
|
||||
def connect():
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.settimeout(2)
|
||||
client.connect(socket_path)
|
||||
return client, client.makefile("rwb")
|
||||
|
||||
def send(file, method, token="owned-token", params=None):
|
||||
file.write((json.dumps({"id": 1, "method": method, "token": token, "params": params or {}}) + "\n").encode())
|
||||
file.flush()
|
||||
|
||||
def reply(file):
|
||||
while True:
|
||||
line = file.readline()
|
||||
assert line, "Bridge closed before its reply"
|
||||
value = json.loads(line)
|
||||
if "event" not in value:
|
||||
return value
|
||||
|
||||
client, file = connect()
|
||||
send(file, "health", "foreign-token" if mode == "unauthorized" else "owned-token")
|
||||
if mode == "lost-reply":
|
||||
assert entered.wait(2)
|
||||
elif mode == "unauthorized":
|
||||
assert reply(file)["error"]["type"] == "authorization"
|
||||
else:
|
||||
assert reply(file)["result"]["run_id"] == "owned-run"
|
||||
file.close()
|
||||
client.close()
|
||||
release.set()
|
||||
if mode != "expiry":
|
||||
client, file = connect()
|
||||
send(file, "health")
|
||||
assert reply(file)["result"]["run_id"] == "owned-run"
|
||||
send(file, "shutdown_document", params={"unconfirmed": True})
|
||||
assert reply(file)["result"]["document_closed"] is False
|
||||
send(file, "shutdown_document")
|
||||
assert reply(file)["result"]["document_closed"] is True
|
||||
file.close()
|
||||
client.close()
|
||||
worker.join(2)
|
||||
assert not worker.is_alive(), "Bridge pinned its execution thread"
|
||||
assert not failures, failures
|
||||
assert not Path(socket_path).exists(), "Bridge retained its closed listener"
|
||||
assert requests.count("shutdown_document") == (0 if mode == "expiry" else 2)
|
||||
return {"mode": mode, "requests": requests, "listener_removed": True, "thread_released": True}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps([probe(sys.argv[1], str(Path(sys.argv[2]) / (mode + ".sock")), mode)
|
||||
for mode in ("unauthorized", "disconnect", "lost-reply", "expiry")]))
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
// Model the native helper's external GUI handoff independently of its lifetime.
|
||||
const [modePath, receiptPath] = process.argv.slice(2, 4);
|
||||
const mode = await readFile(modePath, "utf8");
|
||||
const bootstrapIndex = process.argv.indexOf("-Y") + 1;
|
||||
if (bootstrapIndex === 0) throw new Error("Missing Hopper bootstrap argument");
|
||||
const bootstrapPath = process.argv[bootstrapIndex];
|
||||
await writeFile(receiptPath, bootstrapPath);
|
||||
if (mode === "denied" || mode === "denied-after-handoff") {
|
||||
process.stderr.write(
|
||||
"Cannot launch Hopper!\nNot authorized to send Apple events to Hopper Disassembler.\n",
|
||||
async () => {
|
||||
if (mode === "denied-after-handoff") {
|
||||
// A real native helper can create a document before emitting this same
|
||||
// diagnostic when caller literals alter its AppleScript construction.
|
||||
await writeFile(`${receiptPath}.document-created`, "unconfirmed");
|
||||
}
|
||||
process.exit(2);
|
||||
},
|
||||
);
|
||||
} else if (mode === "handoff") {
|
||||
process.exit(0);
|
||||
} else if (mode === "bridge") {
|
||||
await import("./fakeLauncher.mjs");
|
||||
} else {
|
||||
throw new Error(`Unknown Hopper launch admission fixture mode: ${mode}`);
|
||||
}
|
||||
Reference in New Issue
Block a user