210 lines
6.5 KiB
TypeScript
210 lines
6.5 KiB
TypeScript
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
|
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
|
import {
|
|
HopperCancelledError,
|
|
NoBinaryOpenError,
|
|
type AnalysisError,
|
|
} from "../domain/errors.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import type {
|
|
AnalysisClient,
|
|
AnalysisClientFactory,
|
|
AnalysisOperationPort,
|
|
AnalysisProvider,
|
|
} from "./AnalysisProvider.js";
|
|
|
|
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
|
export interface BinarySessionPort extends AnalysisOperationPort {
|
|
open(
|
|
path: string,
|
|
options?: {
|
|
readonly signal?: AbortSignal;
|
|
readonly targetKind?: BinaryTarget["kind"];
|
|
},
|
|
): Promise<Result<BinaryTarget, AnalysisError>>;
|
|
close(): Promise<Result<null, AnalysisError>>;
|
|
status(): JsonValue;
|
|
activeTarget(): BinaryTarget | undefined;
|
|
}
|
|
|
|
/**
|
|
* Owns the single active target shared by CLI and MCP adapters.
|
|
*
|
|
* Target transitions are serialized because each client dispatches Hopper API
|
|
* work on its dedicated Python thread, and switching targets tears that bridge
|
|
* down. A failed switch recreates the previous target instead of retaining a
|
|
* client whose bridge was already shut down.
|
|
*/
|
|
export class BinarySession implements BinarySessionPort {
|
|
#active:
|
|
| { readonly target: BinaryTarget; readonly client: AnalysisClient }
|
|
| undefined;
|
|
#transition: Promise<void> = Promise.resolve();
|
|
readonly #calls = new Set<Promise<unknown>>();
|
|
readonly #createClient: AnalysisClientFactory;
|
|
|
|
constructor(readonly provider: AnalysisProvider | AnalysisClientFactory) {
|
|
this.#createClient =
|
|
typeof provider === "function"
|
|
? provider
|
|
: (target) => provider.createClient(target);
|
|
}
|
|
|
|
/**
|
|
* Open or switch targets after draining calls against the current target.
|
|
* Returns the switch failure even if best-effort restoration also fails.
|
|
*/
|
|
open(
|
|
path: string,
|
|
options: {
|
|
readonly signal?: AbortSignal;
|
|
readonly targetKind?: BinaryTarget["kind"];
|
|
} = {},
|
|
): Promise<Result<BinaryTarget, AnalysisError>> {
|
|
return this.#serialize(async () => {
|
|
if (isAborted(options.signal)) return err(new HopperCancelledError());
|
|
const parsed = await parseBinaryTarget(
|
|
path,
|
|
process.cwd(),
|
|
process.arch,
|
|
options.targetKind,
|
|
);
|
|
if (!parsed.ok) return parsed;
|
|
if (isAborted(options.signal)) return err(new HopperCancelledError());
|
|
if (this.#active?.target.path === parsed.value.path)
|
|
return ok(parsed.value);
|
|
await this.#drainCalls();
|
|
const previous = this.#active;
|
|
this.#active = undefined;
|
|
await previous?.client.close();
|
|
const client = this.#createClient(parsed.value);
|
|
const started = await client.execute("health", {}, options);
|
|
if (!started.ok) {
|
|
await client.close();
|
|
await this.#restore(previous);
|
|
return started;
|
|
}
|
|
if (isAborted(options.signal)) {
|
|
await client.close();
|
|
await this.#restore(previous);
|
|
return err(new HopperCancelledError());
|
|
}
|
|
this.#active = { target: parsed.value, client };
|
|
return ok(parsed.value);
|
|
});
|
|
}
|
|
|
|
/** Close the active target, if any. */
|
|
close(): Promise<Result<null, AnalysisError>> {
|
|
return this.#serialize(async () => {
|
|
const previous = this.#active;
|
|
this.#active = undefined;
|
|
await this.#drainCalls();
|
|
await previous?.client.close();
|
|
return ok(null);
|
|
});
|
|
}
|
|
|
|
/** Describe the active binary session. */
|
|
status(): JsonValue {
|
|
const target = this.#active?.target;
|
|
return target === undefined
|
|
? { open: false }
|
|
: {
|
|
open: true,
|
|
path: target.path,
|
|
sha256: target.sha256,
|
|
format: target.format,
|
|
kind: target.kind,
|
|
architecture: target.architecture ?? null,
|
|
};
|
|
}
|
|
|
|
/** Return the immutable artifact identity captured before Hopper launched. */
|
|
activeTarget(): BinaryTarget | undefined {
|
|
return this.#active?.target;
|
|
}
|
|
|
|
/**
|
|
* Invoke a Hopper tool against the active target.
|
|
* Calls may overlap, but a pending target transition prevents new calls from
|
|
* entering until the transition has settled.
|
|
*/
|
|
async execute(
|
|
name: string,
|
|
arguments_: Readonly<Record<string, JsonValue>>,
|
|
options?: { readonly signal?: AbortSignal },
|
|
): Promise<Result<JsonValue, AnalysisError>> {
|
|
const transitioned = await this.#waitForTransition(options?.signal);
|
|
if (!transitioned.ok) return transitioned;
|
|
const active = this.#active;
|
|
if (active === undefined) return err(new NoBinaryOpenError());
|
|
const call = active.client.execute(name, arguments_, options);
|
|
this.#calls.add(call);
|
|
try {
|
|
return await call;
|
|
} finally {
|
|
this.#calls.delete(call);
|
|
}
|
|
}
|
|
|
|
#serialize<T>(operation: () => Promise<T>): Promise<T> {
|
|
const result = this.#transition.then(operation, operation);
|
|
this.#transition = result.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
async #drainCalls(): Promise<void> {
|
|
await Promise.allSettled(this.#calls);
|
|
}
|
|
|
|
async #restore(
|
|
previous:
|
|
| { readonly target: BinaryTarget; readonly client: AnalysisClient }
|
|
| undefined,
|
|
): Promise<void> {
|
|
if (previous === undefined) return;
|
|
const client = this.#createClient(previous.target);
|
|
const started = await client.execute("health", {});
|
|
if (started.ok) this.#active = { target: previous.target, client };
|
|
else await client.close();
|
|
}
|
|
|
|
async #waitForTransition(
|
|
signal?: AbortSignal,
|
|
): Promise<Result<undefined, HopperCancelledError>> {
|
|
if (signal?.aborted === true) return err(new HopperCancelledError());
|
|
if (signal === undefined) {
|
|
await this.#transition;
|
|
return ok(undefined);
|
|
}
|
|
return new Promise((resolve) => {
|
|
const onAbort = (): void => {
|
|
resolve(err(new HopperCancelledError()));
|
|
};
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
this.#transition.then(
|
|
() => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve(
|
|
signal.aborted ? err(new HopperCancelledError()) : ok(undefined),
|
|
);
|
|
},
|
|
() => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve(
|
|
signal.aborted ? err(new HopperCancelledError()) : ok(undefined),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
|
signal?.aborted === true;
|