fix(targets): cancel startup and probe PE offsets
This commit is contained in:
parent
15e8cf3234
commit
66b8d6ea90
|
|
@ -1,5 +1,11 @@
|
|||
import { constants } from "node:fs";
|
||||
import { access, open, realpath, stat } from "node:fs/promises";
|
||||
import {
|
||||
access,
|
||||
open,
|
||||
realpath,
|
||||
stat,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
import { BinaryTargetError } from "./errors.js";
|
||||
|
|
@ -44,15 +50,12 @@ export const parseBinaryTarget = async (
|
|||
)
|
||||
return ok({ path, kind: "database", format: "hopper", loaderArgs: [] });
|
||||
const handle = await open(path, "r");
|
||||
let bytes: Buffer;
|
||||
let detected: Result<ExecutableMetadata, string>;
|
||||
try {
|
||||
bytes = Buffer.alloc(4096);
|
||||
const read = await handle.read(bytes, 0, bytes.length, 0);
|
||||
bytes = bytes.subarray(0, read.bytesRead);
|
||||
detected = await readExecutableMetadata(handle, hostArchitecture);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const detected = parseExecutableHeader(bytes, hostArchitecture);
|
||||
if (!detected.ok) return err(new BinaryTargetError(path, detected.error));
|
||||
return ok({ path, kind: "executable", ...detected.value });
|
||||
} catch (cause: unknown) {
|
||||
|
|
@ -67,6 +70,42 @@ type ExecutableMetadata = Pick<
|
|||
"format" | "architecture" | "availableArchitectures" | "loaderArgs"
|
||||
>;
|
||||
|
||||
const readExecutableMetadata = async (
|
||||
handle: FileHandle,
|
||||
hostArchitecture: NodeJS.Architecture,
|
||||
): Promise<Result<ExecutableMetadata, string>> => {
|
||||
const prefix = Buffer.alloc(4096);
|
||||
const prefixRead = await handle.read(prefix, 0, prefix.length, 0);
|
||||
const bytes = prefix.subarray(0, prefixRead.bytesRead);
|
||||
if (bytes.length >= 64 && bytes[0] === 0x4d && bytes[1] === 0x5a) {
|
||||
const offset = bytes.readUInt32LE(0x3c);
|
||||
if (offset > bytes.length - 6) {
|
||||
const record = Buffer.alloc(6);
|
||||
const recordRead = await handle.read(record, 0, record.length, offset);
|
||||
return recordRead.bytesRead === record.length
|
||||
? parsePeRecord(record)
|
||||
: err("invalid or truncated PE header");
|
||||
}
|
||||
}
|
||||
if (bytes.length >= 8) {
|
||||
const magic = bytes.readUInt32BE(0);
|
||||
if ([0xcafebabf, 0xbfbafeca].includes(magic)) {
|
||||
const little = magic === 0xbfbafeca;
|
||||
const count = little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4);
|
||||
const required = 8 + count * 32;
|
||||
if (count <= 128 && required > bytes.length) {
|
||||
const header = Buffer.alloc(required);
|
||||
const headerRead = await handle.read(header, 0, header.length, 0);
|
||||
return parseExecutableHeader(
|
||||
header.subarray(0, headerRead.bytesRead),
|
||||
hostArchitecture,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parseExecutableHeader(bytes, hostArchitecture);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse supported executable headers without I/O and derive explicit Hopper
|
||||
* loader arguments. The caller must supply the host architecture used for FAT
|
||||
|
|
@ -175,12 +214,14 @@ const parseElf = (bytes: Buffer): Result<ExecutableMetadata, string> => {
|
|||
const parsePe = (bytes: Buffer): Result<ExecutableMetadata, string> => {
|
||||
if (bytes.length < 64) return err("truncated PE DOS header");
|
||||
const offset = bytes.readUInt32LE(0x3c);
|
||||
if (
|
||||
offset > bytes.length - 6 ||
|
||||
bytes.toString("binary", offset, offset + 4) !== "PE\u0000\u0000"
|
||||
)
|
||||
if (offset > bytes.length - 6) return err("invalid or truncated PE header");
|
||||
return parsePeRecord(bytes.subarray(offset, offset + 6));
|
||||
};
|
||||
|
||||
const parsePeRecord = (record: Buffer): Result<ExecutableMetadata, string> => {
|
||||
if (record.length < 6 || record.toString("binary", 0, 4) !== "PE\u0000\u0000")
|
||||
return err("invalid or truncated PE header");
|
||||
const architecture = peArchitecture(bytes.readUInt16LE(offset + 4));
|
||||
const architecture = peArchitecture(record.readUInt16LE(4));
|
||||
if (architecture === undefined) return err("unsupported PE architecture");
|
||||
return ok({
|
||||
format: "pe",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { chmod, writeFile } from "node:fs/promises";
|
|||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { HopperStartError } from "../domain/errors.js";
|
||||
import { HopperCancelledError, HopperStartError } from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
|
@ -27,7 +27,8 @@ export interface BridgeLaunch {
|
|||
export interface BridgeLauncher {
|
||||
launch(
|
||||
session: BridgeSession,
|
||||
): Promise<Result<BridgeLaunch, HopperStartError>>;
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>>;
|
||||
}
|
||||
|
||||
export interface HopperApplicationLauncherOptions {
|
||||
|
|
@ -56,7 +57,8 @@ export class HopperApplicationLauncher implements BridgeLauncher {
|
|||
|
||||
async launch(
|
||||
session: BridgeSession,
|
||||
): Promise<Result<BridgeLaunch, HopperStartError>> {
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>> {
|
||||
const bootstrapPath = `${session.directory}/bootstrap.py`;
|
||||
const source = [
|
||||
`REA_SOCKET = ${JSON.stringify(session.socketPath)}`,
|
||||
|
|
@ -82,7 +84,13 @@ export class HopperApplicationLauncher implements BridgeLauncher {
|
|||
this.options.targetPath,
|
||||
];
|
||||
try {
|
||||
await prepareHopperApplication(this.options.launcherPath);
|
||||
if (options.signal?.aborted === true)
|
||||
return err(new HopperCancelledError());
|
||||
const prepared = await prepareHopperApplication(
|
||||
this.options.launcherPath,
|
||||
options.signal,
|
||||
);
|
||||
if (!prepared) return err(new HopperCancelledError());
|
||||
const child = spawn(this.options.launcherPath, args, {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
|
|
@ -110,11 +118,12 @@ export class HopperApplicationLauncher implements BridgeLauncher {
|
|||
|
||||
const prepareHopperApplication = async (
|
||||
launcherPath: string,
|
||||
): Promise<void> => {
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
const appBundle = hopperApplicationBundle(launcherPath);
|
||||
if (appBundle === undefined) return;
|
||||
if (appBundle === undefined) return signal?.aborted !== true;
|
||||
const executablePath = join(appBundle, "Contents/MacOS/Hopper Disassembler");
|
||||
if (await processIsRunning(executablePath)) return;
|
||||
if (await processIsRunning(executablePath)) return signal?.aborted !== true;
|
||||
// This reduces activation when Hopper is cold, but the vendor launcher that
|
||||
// follows may still activate its window. See HopperApplicationLauncher.
|
||||
await execFileAsync("/usr/bin/open", [
|
||||
|
|
@ -123,9 +132,25 @@ const prepareHopperApplication = async (
|
|||
"-a",
|
||||
appBundle,
|
||||
]);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, HOPPER_BACKGROUND_STARTUP_MS),
|
||||
);
|
||||
return waitForDelay(HOPPER_BACKGROUND_STARTUP_MS, signal);
|
||||
};
|
||||
|
||||
const waitForDelay = (
|
||||
milliseconds: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (signal?.aborted === true) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, milliseconds);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const hopperApplicationBundle = (launcherPath: string): string | undefined => {
|
||||
|
|
|
|||
|
|
@ -81,12 +81,15 @@ export class HopperClient {
|
|||
}
|
||||
|
||||
/** Launch the bridge once and complete its authenticated health handshake. */
|
||||
start(): Promise<Result<HopperServerInfo, HopperError>> {
|
||||
this.#startPromise ??= this.#start();
|
||||
start(signal?: AbortSignal): Promise<Result<HopperServerInfo, HopperError>> {
|
||||
this.#startPromise ??= this.#start(signal);
|
||||
return this.#startPromise;
|
||||
}
|
||||
|
||||
async #start(): Promise<Result<HopperServerInfo, HopperError>> {
|
||||
async #start(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<HopperServerInfo, HopperError>> {
|
||||
if (signal?.aborted === true) return err(new HopperCancelledError());
|
||||
if (this.#socket !== undefined || this.#directory !== undefined) {
|
||||
return err(new HopperProtocolError("Hopper client is already started"));
|
||||
}
|
||||
|
|
@ -97,18 +100,21 @@ export class HopperClient {
|
|||
}
|
||||
const socketPath = join(this.#directory, "bridge.sock");
|
||||
this.#token = randomBytes(32).toString("hex");
|
||||
const launched = await this.#options.launcher.launch({
|
||||
directory: this.#directory,
|
||||
socketPath,
|
||||
token: this.#token,
|
||||
});
|
||||
const launched = await this.#options.launcher.launch(
|
||||
{
|
||||
directory: this.#directory,
|
||||
socketPath,
|
||||
token: this.#token,
|
||||
},
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
if (!launched.ok) {
|
||||
await this.close();
|
||||
return launched;
|
||||
}
|
||||
this.#launch = launched.value;
|
||||
this.#attachLauncher(launched.value);
|
||||
const connected = await this.#connect(socketPath);
|
||||
const connected = await this.#connect(socketPath, signal);
|
||||
if (!connected.ok) {
|
||||
await this.close();
|
||||
return connected;
|
||||
|
|
@ -118,6 +124,7 @@ export class HopperClient {
|
|||
{},
|
||||
{
|
||||
timeoutMs: this.#options.startupTimeoutMs,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
if (!health.ok) {
|
||||
|
|
@ -138,7 +145,7 @@ export class HopperClient {
|
|||
readonly timeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<Result<JsonValue, HopperError>> {
|
||||
const started = await this.start();
|
||||
const started = await this.start(options.signal);
|
||||
if (!started.ok) return started;
|
||||
return this.#request(name, arguments_, options);
|
||||
}
|
||||
|
|
@ -177,14 +184,19 @@ export class HopperClient {
|
|||
}
|
||||
}
|
||||
|
||||
async #connect(socketPath: string): Promise<Result<undefined, HopperError>> {
|
||||
async #connect(
|
||||
socketPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<undefined, HopperError>> {
|
||||
const deadline = Date.now() + this.#options.startupTimeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted === true) return err(new HopperCancelledError());
|
||||
if (this.#closing) return err(new HopperProcessError(null));
|
||||
try {
|
||||
await access(socketPath);
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (!(await waitForDelay(50, signal)))
|
||||
return err(new HopperCancelledError());
|
||||
continue;
|
||||
}
|
||||
const attempt = await connectOnce(socketPath);
|
||||
|
|
@ -193,7 +205,8 @@ export class HopperClient {
|
|||
this.#attachSocket(attempt.value);
|
||||
return ok(undefined);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (!(await waitForDelay(50, signal)))
|
||||
return err(new HopperCancelledError());
|
||||
}
|
||||
return err(new HopperTimeoutError(this.#options.startupTimeoutMs));
|
||||
}
|
||||
|
|
@ -329,6 +342,24 @@ export class HopperClient {
|
|||
}
|
||||
}
|
||||
|
||||
const waitForDelay = (
|
||||
milliseconds: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (signal?.aborted === true) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, milliseconds);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const connectOnce = async (
|
||||
socketPath: string,
|
||||
): Promise<Result<Socket, HopperStartError>> =>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,17 @@ describe("binary target I/O", () => {
|
|||
loaderArgs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a PE header beyond the initial probe", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
|
||||
const path = join(directory, "delayed.exe");
|
||||
await writeFile(path, pe(0x8664, 8192));
|
||||
const result = await parseBinaryTarget(path, directory, "x64");
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
format: "pe",
|
||||
architecture: "x86_64",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const elf = (class_: number, endian: number, machine: number): Buffer => {
|
||||
|
|
@ -118,11 +129,11 @@ const fat = (cpus: readonly number[], declared = cpus.length): Buffer => {
|
|||
cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20));
|
||||
return bytes;
|
||||
};
|
||||
const pe = (machine: number): Buffer => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
const pe = (machine: number, offset = 64): Buffer => {
|
||||
const bytes = Buffer.alloc(Math.max(128, offset + 6));
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
bytes.writeUInt32LE(64, 0x3c);
|
||||
bytes.write("PE\0\0", 64, "binary");
|
||||
bytes.writeUInt16LE(machine, 68);
|
||||
bytes.writeUInt32LE(offset, 0x3c);
|
||||
bytes.write("PE\0\0", offset, "binary");
|
||||
bytes.writeUInt16LE(machine, offset + 4);
|
||||
return bytes;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,6 +41,23 @@ class FixtureLauncher implements BridgeLauncher {
|
|||
}
|
||||
}
|
||||
|
||||
class SilentLauncher implements BridgeLauncher {
|
||||
launch() {
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
process: spawn(
|
||||
process.execPath,
|
||||
["-e", "setInterval(() => {}, 1000)"],
|
||||
{
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
},
|
||||
),
|
||||
ownsProcessLifetime: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const clients: HopperClient[] = [];
|
||||
const startClient = async () => {
|
||||
const client = new HopperClient({
|
||||
|
|
@ -136,6 +153,24 @@ describe("HopperClient", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("cancels bridge startup without waiting for the startup timeout", async () => {
|
||||
const client = new HopperClient({
|
||||
launcher: new SilentLauncher(),
|
||||
startupTimeoutMs: 10_000,
|
||||
});
|
||||
clients.push(client);
|
||||
const controller = new AbortController();
|
||||
const startedAt = Date.now();
|
||||
const pending = client.callTool("echo", {}, { signal: controller.signal });
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 10);
|
||||
const result = await pending;
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError");
|
||||
expect(Date.now() - startedAt).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("ignores more than 1,024 late responses without corrupting the session", async () => {
|
||||
const client = await startClient();
|
||||
const timedOut = await Promise.all(
|
||||
|
|
|
|||
Loading…
Reference in New Issue