fix(targets): cancel startup and probe PE offsets

This commit is contained in:
morluto 2026-07-12 09:04:42 +08:00
parent 15e8cf3234
commit 66b8d6ea90
5 changed files with 182 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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

View File

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