fix(native): retain LLDB cleanup ownership through client close
This commit is contained in:
parent
0bfb6e00ab
commit
7ad353fa8b
|
|
@ -1,5 +1,6 @@
|
|||
import type { JsonValue } from "./jsonValue.js";
|
||||
import { ProviderAdapterError } from "./providerAdapterError.js";
|
||||
import type { AnalysisPartialObservation } from "./analysisErrorBase.js";
|
||||
|
||||
/** Provider resources could not be proven closed after bounded cleanup. */
|
||||
export class ProviderCleanupError extends ProviderAdapterError {
|
||||
|
|
@ -13,10 +14,21 @@ export class ProviderCleanupError extends ProviderAdapterError {
|
|||
resources: readonly string[],
|
||||
diagnostics: Readonly<Record<string, JsonValue>>,
|
||||
/** Identify the operation owning these resources; binary callers retain their default. */
|
||||
options?: ErrorOptions & { readonly operation?: string },
|
||||
options?: ErrorOptions & {
|
||||
readonly operation?: string;
|
||||
readonly partialObservation?: AnalysisPartialObservation;
|
||||
},
|
||||
) {
|
||||
const { operation = "close_binary", ...errorOptions } = options ?? {};
|
||||
super(providerId, operation, { ...errorOptions, diagnostics });
|
||||
const {
|
||||
operation = "close_binary",
|
||||
partialObservation,
|
||||
...errorOptions
|
||||
} = options ?? {};
|
||||
super(providerId, operation, {
|
||||
...errorOptions,
|
||||
...(partialObservation === undefined ? {} : { partialObservation }),
|
||||
diagnostics,
|
||||
});
|
||||
this.cleanupResources = [...resources];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,9 @@ import {
|
|||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import type {
|
||||
AnalysisCleanupObservation,
|
||||
AnalysisError,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
||||
import type { NativeCallPartialObservation } from "../domain/native/nativeCallPartialObservation.js";
|
||||
import {
|
||||
nativeCallEventSchema,
|
||||
|
|
@ -23,6 +22,7 @@ import {
|
|||
type NativeCallObservationInput,
|
||||
} from "../domain/native/nativeCallObservation.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import {
|
||||
|
|
@ -32,7 +32,6 @@ import {
|
|||
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
||||
import {
|
||||
observeProcessStartIdentity,
|
||||
prepareProcessOwnershipInspection,
|
||||
signalProcessWithStartIdentity,
|
||||
} from "../process/ProcessOwnershipObservation.js";
|
||||
import type { ProcessIdentityObservation } from "../process/ProcessOwnership.js";
|
||||
|
|
@ -158,6 +157,8 @@ export interface NativeCallTracer {
|
|||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<NativeCallTrace, AnalysisError>>;
|
||||
/** Retry cleanup retained after a trace could not verify process exit. */
|
||||
close(): Promise<Result<null, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** Remediation for a target LLDB may not debug. */
|
||||
|
|
@ -167,6 +168,8 @@ const ATTACH_REMEDIATION =
|
|||
/** Production tracer: `lldb --batch` running the REA LLDB bridge. */
|
||||
export class LldbCallTracer implements NativeCallTracer {
|
||||
private readonly environment: NodeJS.ProcessEnv;
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#pendingCleanup: RetainedLldbResources | undefined;
|
||||
constructor(
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
private readonly launch: typeof runLldb = runLldb,
|
||||
|
|
@ -183,6 +186,15 @@ export class LldbCallTracer implements NativeCallTracer {
|
|||
request: Parameters<NativeCallTracer["trace"]>[0],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<NativeCallTrace, AnalysisError>> {
|
||||
return this.#serialize(() => this.#trace(request, signal));
|
||||
}
|
||||
|
||||
async #trace(
|
||||
request: Parameters<NativeCallTracer["trace"]>[0],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<NativeCallTrace, AnalysisError>> {
|
||||
const pendingCleanup = await this.#retryPendingCleanup();
|
||||
if (pendingCleanup !== undefined) return err(pendingCleanup);
|
||||
const lldb = await this.resolveTool("lldb", signal);
|
||||
if (!lldb.ok)
|
||||
return err(
|
||||
|
|
@ -196,30 +208,155 @@ export class LldbCallTracer implements NativeCallTracer {
|
|||
),
|
||||
);
|
||||
const root = await PrivateRuntimeRoot.create({ prefix: "rea-lldb-" });
|
||||
const paths = lldbRuntimePaths(root.path);
|
||||
let result: Result<NativeCallTrace, AnalysisError>;
|
||||
try {
|
||||
return await this.#run(lldb.value, root.path, request, signal);
|
||||
} finally {
|
||||
await root.close();
|
||||
result = await this.#run(lldb.value, root, request, signal);
|
||||
} catch (cause: unknown) {
|
||||
const partialObservation = await partialNativeObservation({
|
||||
request,
|
||||
pidPath: paths.pid,
|
||||
stdoutPath: paths.stdoutCapture,
|
||||
stderrPath: paths.stderrCapture,
|
||||
observationPath: paths.observations,
|
||||
version: null,
|
||||
reason: "tracer-failure",
|
||||
});
|
||||
const retained = this.#pendingCleanup;
|
||||
const resources =
|
||||
retained === undefined ? [] : await lldbCleanupResources(retained);
|
||||
result = err(
|
||||
new ProviderAdapterError(PROVIDER, OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
partialObservation,
|
||||
...(retained === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: "LLDB trace failed while owned resources remain",
|
||||
resources,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (this.#pendingCleanup?.root === root) return result;
|
||||
try {
|
||||
await root.close();
|
||||
} catch (cause: unknown) {
|
||||
this.#pendingCleanup = {
|
||||
root,
|
||||
supervisor: undefined,
|
||||
pidPath: paths.pid,
|
||||
targetIdentity: undefined,
|
||||
processStopped: true,
|
||||
targetStopped: true,
|
||||
};
|
||||
const partialObservation = result.ok
|
||||
? partialObservationFromTrace(request, result.value)
|
||||
: result.error.partialObservation;
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
PROVIDER,
|
||||
[root.path],
|
||||
{
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
...(result.ok
|
||||
? {}
|
||||
: {
|
||||
execution_failure: projectAnalysisError(result.error).code,
|
||||
primary_failure: result.error.message,
|
||||
}),
|
||||
},
|
||||
{
|
||||
operation: OPERATION,
|
||||
cause: result.ok
|
||||
? cause
|
||||
: new AggregateError(
|
||||
[result.error, cause],
|
||||
"LLDB trace and runtime cleanup both failed",
|
||||
),
|
||||
...(partialObservation === undefined ? {} : { partialObservation }),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Retry retained LLDB cleanup without abandoning its process or runtime owner. */
|
||||
async close(): Promise<Result<null, AnalysisError>> {
|
||||
return this.#serialize(() => this.#close());
|
||||
}
|
||||
|
||||
async #close(): Promise<Result<null, AnalysisError>> {
|
||||
const failure = await this.#retryPendingCleanup();
|
||||
return failure === undefined ? ok(null) : err(failure);
|
||||
}
|
||||
|
||||
#serialize<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const call = this.#tail.then(operation, operation);
|
||||
this.#tail = call.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return call;
|
||||
}
|
||||
|
||||
async #retryPendingCleanup(): Promise<ProviderCleanupError | undefined> {
|
||||
const owner = this.#pendingCleanup;
|
||||
if (owner === undefined) return undefined;
|
||||
let firstFailure: string | undefined;
|
||||
if (!owner.processStopped) {
|
||||
const stopped = await owner.supervisor?.stop();
|
||||
if (stopped?.status === "incomplete") firstFailure = stopped.reason;
|
||||
else {
|
||||
owner.supervisor?.dispose();
|
||||
owner.processStopped = true;
|
||||
}
|
||||
}
|
||||
if (firstFailure === undefined && !owner.targetStopped) {
|
||||
const targetTermination = await ensureTerminated(
|
||||
owner.pidPath,
|
||||
owner.targetIdentity,
|
||||
true,
|
||||
);
|
||||
if (targetTermination !== "terminated")
|
||||
firstFailure = targetTerminationReason(targetTermination);
|
||||
else owner.targetStopped = true;
|
||||
}
|
||||
if (firstFailure === undefined) {
|
||||
try {
|
||||
await owner.root.close();
|
||||
this.#pendingCleanup = undefined;
|
||||
return undefined;
|
||||
} catch (cause: unknown) {
|
||||
firstFailure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
const resources = await lldbCleanupResources(owner);
|
||||
return new ProviderCleanupError(
|
||||
PROVIDER,
|
||||
resources,
|
||||
{
|
||||
reason: firstFailure ?? "LLDB cleanup remains incomplete",
|
||||
resources,
|
||||
},
|
||||
{ operation: OPERATION },
|
||||
);
|
||||
}
|
||||
|
||||
async #run(
|
||||
lldb: ResolvedTool,
|
||||
directory: string,
|
||||
root: PrivateRuntimeRoot,
|
||||
request: Parameters<NativeCallTracer["trace"]>[0],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<NativeCallTrace, AnalysisError>> {
|
||||
const paths = {
|
||||
config: join(directory, "config.json"),
|
||||
result: join(directory, "result.json"),
|
||||
pid: join(directory, "pid"),
|
||||
identityAck: join(directory, "identity.ack"),
|
||||
stdout: join(directory, "stdout"),
|
||||
stderr: join(directory, "stderr"),
|
||||
stdoutCapture: join(directory, "stdout.capture"),
|
||||
stderrCapture: join(directory, "stderr.capture"),
|
||||
observations: join(directory, "observations.jsonl"),
|
||||
};
|
||||
const directory = root.path;
|
||||
const paths = lldbRuntimePaths(directory);
|
||||
const { input } = request;
|
||||
await writeFile(
|
||||
paths.config,
|
||||
|
|
@ -251,7 +388,6 @@ export class LldbCallTracer implements NativeCallTracer {
|
|||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await prepareProcessOwnershipInspection(signal);
|
||||
const exited = await this.launch(
|
||||
lldb.path,
|
||||
[
|
||||
|
|
@ -271,15 +407,22 @@ export class LldbCallTracer implements NativeCallTracer {
|
|||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
const retained: RetainedLldbResources = {
|
||||
root,
|
||||
supervisor: exited.cleanupOwner,
|
||||
pidPath: paths.pid,
|
||||
targetIdentity: exited.targetIdentity,
|
||||
processStopped: exited.cleanupOwner === undefined,
|
||||
targetStopped: !exited.targetLaunched,
|
||||
};
|
||||
this.#pendingCleanup = retained;
|
||||
const output =
|
||||
exited.kind === "exited"
|
||||
? await readTracerOutput(paths.result)
|
||||
: undefined;
|
||||
let targetTermination = await ensureTerminated(
|
||||
paths.pid,
|
||||
exited.targetIdentity,
|
||||
true,
|
||||
);
|
||||
let targetTermination: TargetTermination = exited.targetLaunched
|
||||
? await ensureTerminated(paths.pid, exited.targetIdentity, true)
|
||||
: "terminated";
|
||||
// These two bridge outcomes are emitted before a target process exists.
|
||||
// Other missing-PID cases, including a missing result, remain unknown.
|
||||
if (
|
||||
|
|
@ -288,6 +431,9 @@ export class LldbCallTracer implements NativeCallTracer {
|
|||
(await readTargetPid(paths.pid)) === undefined
|
||||
)
|
||||
targetTermination = "terminated";
|
||||
retained.targetStopped = targetTermination === "terminated";
|
||||
if (retained.processStopped && retained.targetStopped)
|
||||
this.#pendingCleanup = undefined;
|
||||
const terminated = targetTermination === "terminated";
|
||||
const cleanupDetails = [
|
||||
exited.cleanupFailure,
|
||||
|
|
@ -298,16 +444,7 @@ export class LldbCallTracer implements NativeCallTracer {
|
|||
? undefined
|
||||
: {
|
||||
reason: cleanupDetails.join("; "),
|
||||
resources: [
|
||||
...(exited.cleanupFailure === undefined
|
||||
? []
|
||||
: ["lldb-process-group"]),
|
||||
...(terminated
|
||||
? []
|
||||
: [
|
||||
`native-target:${String((await readTargetPid(paths.pid)) ?? "unknown")}`,
|
||||
]),
|
||||
],
|
||||
resources: await lldbCleanupResources(retained),
|
||||
};
|
||||
const partialReason =
|
||||
exited.kind === "cancelled"
|
||||
|
|
@ -357,20 +494,38 @@ export class LldbCallTracer implements NativeCallTracer {
|
|||
);
|
||||
if (exited.cleanupFailure !== undefined)
|
||||
return err(
|
||||
new ProviderAdapterError(PROVIDER, OPERATION, {
|
||||
cleanup: lifecycleCleanup ?? {
|
||||
reason: exited.cleanupFailure,
|
||||
resources: ["lldb-process-group"],
|
||||
},
|
||||
diagnostics: {
|
||||
new ProviderCleanupError(
|
||||
PROVIDER,
|
||||
await lldbCleanupResources(retained),
|
||||
{
|
||||
reason: "LLDB process-group cleanup could not be verified",
|
||||
cleanup_failure: exited.cleanupFailure,
|
||||
...(lifecycleCleanup === undefined
|
||||
? {}
|
||||
: { target_cleanup: lifecycleCleanup }),
|
||||
},
|
||||
partialObservation: await getPartialObservation(),
|
||||
}),
|
||||
{
|
||||
operation: OPERATION,
|
||||
partialObservation: await getPartialObservation(),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!terminated)
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
PROVIDER,
|
||||
await lldbCleanupResources(retained),
|
||||
{
|
||||
reason: "LLDB target process termination could not be verified",
|
||||
...(lifecycleCleanup === undefined
|
||||
? {}
|
||||
: { target_cleanup: lifecycleCleanup }),
|
||||
},
|
||||
{
|
||||
operation: OPERATION,
|
||||
partialObservation: await getPartialObservation(),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (output === undefined)
|
||||
return err(
|
||||
|
|
@ -532,6 +687,43 @@ const partialNativeObservation = async (options: {
|
|||
});
|
||||
};
|
||||
|
||||
const partialObservationFromTrace = (
|
||||
request: Parameters<NativeCallTracer["trace"]>[0],
|
||||
trace: NativeCallTrace,
|
||||
): NativeCallPartialObservation =>
|
||||
projectLldbPartialObservation({
|
||||
target: {
|
||||
path: request.executable,
|
||||
sha256: request.expectedSha256,
|
||||
architecture: request.architecture,
|
||||
arguments: request.input.arguments,
|
||||
environment: request.input.environment,
|
||||
working_directory: request.input.working_directory ?? null,
|
||||
},
|
||||
pid: trace.run.pid,
|
||||
stdout: trace.stdout,
|
||||
stderr: trace.stderr,
|
||||
version: trace.run.version,
|
||||
journal: {
|
||||
events: trace.run.events,
|
||||
otherStops: trace.run.other_stops,
|
||||
limitations: [],
|
||||
},
|
||||
reason: "cleanup-failure",
|
||||
});
|
||||
|
||||
const lldbRuntimePaths = (directory: string) => ({
|
||||
config: join(directory, "config.json"),
|
||||
result: join(directory, "result.json"),
|
||||
pid: join(directory, "pid"),
|
||||
identityAck: join(directory, "identity.ack"),
|
||||
stdout: join(directory, "stdout"),
|
||||
stderr: join(directory, "stderr"),
|
||||
stdoutCapture: join(directory, "stdout.capture"),
|
||||
stderrCapture: join(directory, "stderr.capture"),
|
||||
observations: join(directory, "observations.jsonl"),
|
||||
});
|
||||
|
||||
const readTracerOutput = async (
|
||||
path: string,
|
||||
): Promise<z.infer<typeof tracerOutputSchema> | undefined> => {
|
||||
|
|
@ -588,24 +780,43 @@ const capturedOutput = async (
|
|||
}
|
||||
};
|
||||
|
||||
type LldbExit =
|
||||
interface RetainedLldbResources {
|
||||
readonly root: PrivateRuntimeRoot;
|
||||
readonly supervisor: ProviderProcessSupervisor | undefined;
|
||||
readonly pidPath: string;
|
||||
readonly targetIdentity: string | undefined;
|
||||
processStopped: boolean;
|
||||
targetStopped: boolean;
|
||||
}
|
||||
|
||||
type LldbOutcome =
|
||||
| {
|
||||
readonly kind: "exited";
|
||||
readonly exitCode: number | null;
|
||||
readonly output: string;
|
||||
readonly targetIdentity: string | undefined;
|
||||
readonly cleanupFailure: string | undefined;
|
||||
}
|
||||
| {
|
||||
readonly kind: "cancelled";
|
||||
readonly targetIdentity: string | undefined;
|
||||
readonly cleanupFailure: string | undefined;
|
||||
}
|
||||
| {
|
||||
readonly kind: "timeout";
|
||||
readonly targetIdentity: string | undefined;
|
||||
readonly cleanupFailure: string | undefined;
|
||||
};
|
||||
| { readonly kind: "cancelled" }
|
||||
| { readonly kind: "timeout" };
|
||||
|
||||
type LldbExit = LldbOutcome & {
|
||||
/** False proves LLDB never started and could not have created a target. */
|
||||
readonly targetLaunched: boolean;
|
||||
readonly targetIdentity: string | undefined;
|
||||
readonly cleanupFailure: string | undefined;
|
||||
readonly cleanupOwner?: ProviderProcessSupervisor;
|
||||
};
|
||||
|
||||
const lldbCleanupResources = async (
|
||||
owner: RetainedLldbResources,
|
||||
): Promise<string[]> => [
|
||||
...(owner.processStopped ? [] : ["lldb-process-group"]),
|
||||
...(owner.targetStopped
|
||||
? []
|
||||
: [
|
||||
`native-target:${String((await readTargetPid(owner.pidPath)) ?? "unknown")}`,
|
||||
]),
|
||||
owner.root.path,
|
||||
];
|
||||
|
||||
/** Supervise LLDB as an owned process group while observing its target PID identity. */
|
||||
const runLldb = async (
|
||||
|
|
@ -622,6 +833,7 @@ const runLldb = async (
|
|||
if (options.signal?.aborted === true)
|
||||
return {
|
||||
kind: "cancelled",
|
||||
targetLaunched: false,
|
||||
targetIdentity: undefined,
|
||||
cleanupFailure: undefined,
|
||||
};
|
||||
|
|
@ -631,6 +843,23 @@ const runLldb = async (
|
|||
);
|
||||
let targetIdentity: string | undefined;
|
||||
let supervisor: ProviderProcessSupervisor | undefined;
|
||||
let retained = false;
|
||||
const finish = async (outcome: LldbOutcome): Promise<LldbExit> => {
|
||||
const stopped = await supervisor?.stop();
|
||||
retained = stopped?.status === "incomplete";
|
||||
return {
|
||||
...(deadline.interruption === undefined
|
||||
? outcome
|
||||
: { kind: deadline.interruption }),
|
||||
targetLaunched: supervisor !== undefined,
|
||||
targetIdentity,
|
||||
cleanupFailure:
|
||||
stopped?.status === "incomplete" ? stopped.reason : undefined,
|
||||
...(retained && supervisor !== undefined
|
||||
? { cleanupOwner: supervisor }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
try {
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
command: executable,
|
||||
|
|
@ -668,52 +897,36 @@ const runLldb = async (
|
|||
identityAckWritten = true;
|
||||
}
|
||||
const interruption = deadline.interruption;
|
||||
if (interruption !== undefined) {
|
||||
const stopped = await supervisor.stop();
|
||||
const cleanupFailure =
|
||||
stopped.status === "incomplete" ? stopped.reason : undefined;
|
||||
return interruption === "cancelled"
|
||||
? { kind: "cancelled", targetIdentity, cleanupFailure }
|
||||
: { kind: "timeout", targetIdentity, cleanupFailure };
|
||||
}
|
||||
if (interruption !== undefined)
|
||||
return await finish({ kind: interruption });
|
||||
await waitForAbortableDelay(20, deadline.signal);
|
||||
}
|
||||
const snapshot = supervisor.snapshot();
|
||||
const stopped = await supervisor.stop();
|
||||
const cleanupFailure =
|
||||
stopped.status === "incomplete" ? stopped.reason : undefined;
|
||||
const interruption = deadline.interruption;
|
||||
if (interruption === "cancelled")
|
||||
return { kind: "cancelled", targetIdentity, cleanupFailure };
|
||||
if (interruption === "timeout")
|
||||
return { kind: "timeout", targetIdentity, cleanupFailure };
|
||||
return {
|
||||
kind: "exited",
|
||||
exitCode: snapshot.exitCode ?? null,
|
||||
output: `${snapshot.stdout.text}${snapshot.stderr.text}`.slice(
|
||||
-DIAGNOSTIC_BYTES,
|
||||
),
|
||||
targetIdentity,
|
||||
cleanupFailure,
|
||||
};
|
||||
return await finish(
|
||||
interruption === undefined
|
||||
? {
|
||||
kind: "exited",
|
||||
exitCode: snapshot.exitCode ?? null,
|
||||
output: `${snapshot.stdout.text}${snapshot.stderr.text}`.slice(
|
||||
-DIAGNOSTIC_BYTES,
|
||||
),
|
||||
}
|
||||
: { kind: interruption },
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
const interruption = deadline.interruption;
|
||||
const stopped = await supervisor?.stop();
|
||||
const cleanupFailure =
|
||||
stopped?.status === "incomplete" ? stopped.reason : undefined;
|
||||
return interruption === "timeout"
|
||||
? { kind: "timeout", targetIdentity, cleanupFailure }
|
||||
: interruption === "cancelled"
|
||||
? { kind: "cancelled", targetIdentity, cleanupFailure }
|
||||
: {
|
||||
return await finish(
|
||||
interruption === undefined
|
||||
? {
|
||||
kind: "exited",
|
||||
exitCode: null,
|
||||
output: cause instanceof Error ? cause.message : String(cause),
|
||||
targetIdentity,
|
||||
cleanupFailure,
|
||||
};
|
||||
}
|
||||
: { kind: interruption },
|
||||
);
|
||||
} finally {
|
||||
supervisor?.dispose();
|
||||
if (!retained) supervisor?.dispose();
|
||||
deadline.dispose();
|
||||
}
|
||||
};
|
||||
|
|
@ -724,8 +937,10 @@ const runLldb = async (
|
|||
*/
|
||||
const readTargetPid = async (pidPath: string): Promise<number | undefined> => {
|
||||
try {
|
||||
const pid = Number.parseInt(await readFile(pidPath, "utf8"), 10);
|
||||
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
||||
const contents = (await readFile(pidPath, "utf8")).trim();
|
||||
if (!/^[1-9]\d*$/u.test(contents)) return undefined;
|
||||
const pid = Number(contents);
|
||||
return Number.isSafeInteger(pid) ? pid : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import type { NativeCallTrace, NativeCallTracer } from "./LldbCallTracer.js";
|
||||
import { observeNativeCalls } from "./NativeCallObservation.js";
|
||||
import { nativeMachoTarget } from "../../tests/fixtures/nativeCommands.js";
|
||||
|
|
@ -20,6 +20,10 @@ class NeverStartedTracer implements NativeCallTracer {
|
|||
this.calls += 1;
|
||||
throw new Error("tracer must not run after hash cancellation");
|
||||
}
|
||||
|
||||
close(): Promise<Result<null, AnalysisError>> {
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
}
|
||||
|
||||
describe("native call target digest cancellation", () => {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ import {
|
|||
export class NativeMacOSProvider implements AnalysisProvider {
|
||||
readonly #capabilities: readonly CapabilityDescriptor[];
|
||||
readonly #environment: NodeJS.ProcessEnv;
|
||||
readonly #tracerFactory: () => NativeCallTracer;
|
||||
|
||||
constructor(
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
|
|
@ -89,9 +90,11 @@ export class NativeMacOSProvider implements AnalysisProvider {
|
|||
environment,
|
||||
),
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
private readonly tracer: NativeCallTracer = new LldbCallTracer(environment),
|
||||
tracerFactory?: () => NativeCallTracer,
|
||||
) {
|
||||
this.#environment = snapshotEnvironment(environment, platform);
|
||||
this.#tracerFactory =
|
||||
tracerFactory ?? (() => new LldbCallTracer(this.#environment));
|
||||
this.#capabilities = nativeMacOSCapabilities(platform);
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +110,7 @@ export class NativeMacOSProvider implements AnalysisProvider {
|
|||
return new NativeMacOSClient(
|
||||
target,
|
||||
this.runner,
|
||||
this.tracer,
|
||||
this.#tracerFactory(),
|
||||
this.#environment,
|
||||
);
|
||||
}
|
||||
|
|
@ -246,7 +249,7 @@ class NativeMacOSClient implements AnalysisClient {
|
|||
}
|
||||
|
||||
close(): Promise<Result<null, AnalysisError>> {
|
||||
return Promise.resolve(ok(null));
|
||||
return this.tracer.close();
|
||||
}
|
||||
|
||||
#dispatch(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { NativeMacOSProvider } from "./NativeMacOSProvider.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
NativeFixtureRunner,
|
||||
|
|
@ -57,4 +58,33 @@ describe("native dispatch metadata input boundary", () => {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("closes only the native call tracer owned by that client", async () => {
|
||||
const closes = [0, 0];
|
||||
let next = 0;
|
||||
const provider = new NativeMacOSProvider(
|
||||
{},
|
||||
new NativeFixtureRunner(),
|
||||
"darwin",
|
||||
() => {
|
||||
const index = next++;
|
||||
return {
|
||||
trace: async () => {
|
||||
throw new Error("trace is unused in this lifecycle test");
|
||||
},
|
||||
close: async () => {
|
||||
closes[index] = (closes[index] ?? 0) + 1;
|
||||
return ok(null);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
const first = provider.createClient(nativeMachoTarget("/first"));
|
||||
const second = provider.createClient(nativeMachoTarget("/second"));
|
||||
|
||||
expect((await first.close()).ok).toBe(true);
|
||||
expect(closes).toEqual([1, 0]);
|
||||
expect((await second.close()).ok).toBe(true);
|
||||
expect(closes).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ const tracer: NativeCallTracer & { calls: number } = {
|
|||
}),
|
||||
);
|
||||
},
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
};
|
||||
|
||||
it("routes observe_native_calls through MCP with schema-checked input and output", async () => {
|
||||
|
|
@ -88,7 +89,12 @@ it("routes observe_native_calls through MCP with schema-checked input and output
|
|||
const path = join(directory, "Tool");
|
||||
await writeFile(path, machoHeader());
|
||||
const session = createTestBinarySession(
|
||||
new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin", tracer),
|
||||
new NativeMacOSProvider(
|
||||
{},
|
||||
new NativeFixtureRunner(),
|
||||
"darwin",
|
||||
() => tracer,
|
||||
),
|
||||
);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "native-calls-mcp-test", version: "1" });
|
||||
|
|
@ -138,6 +144,62 @@ it("routes observe_native_calls through MCP with schema-checked input and output
|
|||
}
|
||||
});
|
||||
|
||||
const partialObservationFor = (options: {
|
||||
readonly path: string;
|
||||
readonly sha256: string;
|
||||
readonly arguments: string[];
|
||||
readonly environment: Record<string, string>;
|
||||
readonly workingDirectory: string;
|
||||
readonly reason: "cancelled" | "cleanup-failure";
|
||||
}) =>
|
||||
nativeCallPartialObservationSchema.parse({
|
||||
kind: "native-call-observation",
|
||||
target: {
|
||||
path: options.path,
|
||||
sha256: options.sha256,
|
||||
architecture: "arm64",
|
||||
arguments: options.arguments,
|
||||
environment: options.environment,
|
||||
working_directory: options.workingDirectory,
|
||||
},
|
||||
process: {
|
||||
pid: 4242,
|
||||
stdout: {
|
||||
text: "partial target output",
|
||||
bytes: 21,
|
||||
truncated: true,
|
||||
complete: false,
|
||||
},
|
||||
stderr: {
|
||||
text: "debugger diagnostic",
|
||||
bytes: 19,
|
||||
truncated: false,
|
||||
complete: false,
|
||||
},
|
||||
other_stops: ["signal SIGSTOP"],
|
||||
},
|
||||
debugger: { version: "lldb-fixture" },
|
||||
events: [
|
||||
{
|
||||
sequence: 0,
|
||||
elapsed_ms: 4,
|
||||
thread_id: 3,
|
||||
breakpoint_index: 0,
|
||||
load_address: "0x1000",
|
||||
file_address: "0x1000",
|
||||
module: "Tool",
|
||||
module_path: options.path,
|
||||
symbol: "open",
|
||||
receiver_class: null,
|
||||
selector: null,
|
||||
registers: [{ name: "x0", value: "0x2" }],
|
||||
backtrace: [],
|
||||
},
|
||||
],
|
||||
coverage: { status: "partial", reason: options.reason },
|
||||
limitations: ["Observation ended before the requested window completed."],
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cancelled", "cancelled", undefined],
|
||||
[
|
||||
|
|
@ -161,57 +223,16 @@ it.each([
|
|||
const launchArguments = ["--fixture", "selected argument"];
|
||||
const launchEnvironment = { REA_NATIVE_FIXTURE: "selected value" };
|
||||
const workingDirectory = directory;
|
||||
const partialObservation = nativeCallPartialObservationSchema.parse({
|
||||
kind: "native-call-observation",
|
||||
target: {
|
||||
path,
|
||||
sha256,
|
||||
architecture: "arm64",
|
||||
arguments: launchArguments,
|
||||
environment: launchEnvironment,
|
||||
working_directory: workingDirectory,
|
||||
},
|
||||
process: {
|
||||
pid: 4242,
|
||||
stdout: {
|
||||
text: "partial target output",
|
||||
bytes: 21,
|
||||
truncated: true,
|
||||
complete: false,
|
||||
},
|
||||
stderr: {
|
||||
text: "debugger diagnostic",
|
||||
bytes: 19,
|
||||
truncated: false,
|
||||
complete: false,
|
||||
},
|
||||
other_stops: ["signal SIGSTOP"],
|
||||
},
|
||||
debugger: { version: "lldb-fixture" },
|
||||
events: [
|
||||
{
|
||||
sequence: 0,
|
||||
elapsed_ms: 4,
|
||||
thread_id: 3,
|
||||
breakpoint_index: 0,
|
||||
load_address: "0x1000",
|
||||
file_address: "0x1000",
|
||||
module: "Tool",
|
||||
module_path: path,
|
||||
symbol: "open",
|
||||
receiver_class: null,
|
||||
selector: null,
|
||||
registers: [{ name: "x0", value: "0x2" }],
|
||||
backtrace: [],
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
status: "partial",
|
||||
reason: cleanup === undefined ? "cancelled" : "cleanup-failure",
|
||||
},
|
||||
limitations: ["Observation ended before the requested window completed."],
|
||||
const partialObservation = partialObservationFor({
|
||||
path,
|
||||
sha256,
|
||||
arguments: launchArguments,
|
||||
environment: launchEnvironment,
|
||||
workingDirectory,
|
||||
reason: cleanup === undefined ? "cancelled" : "cleanup-failure",
|
||||
});
|
||||
const tracer: NativeCallTracer = {
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
trace: () =>
|
||||
Promise.resolve(
|
||||
err(
|
||||
|
|
@ -231,7 +252,12 @@ it.each([
|
|||
),
|
||||
};
|
||||
const session = createTestBinarySession(
|
||||
new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin", tracer),
|
||||
new NativeMacOSProvider(
|
||||
{},
|
||||
new NativeFixtureRunner(),
|
||||
"darwin",
|
||||
() => tracer,
|
||||
),
|
||||
);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { access, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
|
||||
import { ok } from "../../../../src/domain/result.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js";
|
||||
import { observeProcessStartIdentity } from "../../../../src/process/ProcessOwnershipObservation.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../../../../src/process/ProviderProcess.js";
|
||||
import type { NativeCallTracer } from "../../../../src/native/LldbCallTracer.js";
|
||||
import { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js";
|
||||
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
|
||||
import {
|
||||
NativeFixtureRunner,
|
||||
nativeMachoTarget,
|
||||
} from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
const request: Parameters<NativeCallTracer["trace"]>[0] = {
|
||||
executable: "/fixture/target",
|
||||
architecture: "arm64",
|
||||
expectedSha256: "0".repeat(64),
|
||||
input: {
|
||||
breakpoints: [{ kind: "function", name: "main" }],
|
||||
arguments: [],
|
||||
environment: {},
|
||||
duration_ms: 100,
|
||||
max_events: 1,
|
||||
argument_registers: 0,
|
||||
backtrace_frames: 0,
|
||||
},
|
||||
};
|
||||
const tool = { path: process.execPath, sha256: "0".repeat(64) };
|
||||
|
||||
const createTracer = (
|
||||
launch?: ConstructorParameters<typeof LldbCallTracer>[1],
|
||||
): LldbCallTracer =>
|
||||
new LldbCallTracer(process.env, launch, async () => ok(tool));
|
||||
|
||||
const createClient = (tracer: NativeCallTracer) =>
|
||||
new NativeMacOSProvider(
|
||||
{},
|
||||
new NativeFixtureRunner(),
|
||||
"darwin",
|
||||
() => tracer,
|
||||
).createClient(nativeMachoTarget(request.executable));
|
||||
|
||||
const runtimeRoot = (error: ProviderCleanupError): string => {
|
||||
const root = error.cleanupResources.find((resource) =>
|
||||
resource.startsWith(`${tmpdir()}/rea-lldb-`),
|
||||
);
|
||||
if (root === undefined) throw new Error("Expected retained runtime root");
|
||||
return root;
|
||||
};
|
||||
|
||||
const liveChild = async () => {
|
||||
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
await once(child, "spawn");
|
||||
if (child.pid === undefined) throw new Error("Fixture process had no PID");
|
||||
return { child, pid: child.pid };
|
||||
};
|
||||
|
||||
const stopChild = async (child: ReturnType<typeof spawn>) => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const exited = once(child, "exit");
|
||||
child.kill("SIGKILL");
|
||||
await exited;
|
||||
}
|
||||
};
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retains a real LLDB process owner and runtime until a later cleanup retry succeeds",
|
||||
async () => {
|
||||
let cleanupFailures = 2;
|
||||
let ownedPid: number | undefined;
|
||||
const tracer = createTracer(async (_exe, _args, options) => {
|
||||
const target = spawn(process.execPath, ["-e", "process.exit(0)"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
await once(target, "exit");
|
||||
if (target.pid === undefined)
|
||||
throw new Error("Fixture target had no PID");
|
||||
await writeFile(options.pidPath, String(target.pid));
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
command: process.execPath,
|
||||
arguments: ["-e", "setInterval(() => {}, 1000)"],
|
||||
runId: randomUUID(),
|
||||
hostEnvironment: options.environment,
|
||||
});
|
||||
ownedPid = launched.process.pid;
|
||||
const supervisor = new ProviderProcessSupervisor({
|
||||
...launched,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup: async () => {
|
||||
if (cleanupFailures-- > 0)
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "fixture transient cleanup failure",
|
||||
};
|
||||
return cleanupOwnedProcessGroup(launched.ownership);
|
||||
},
|
||||
});
|
||||
const stopped = await supervisor.stop();
|
||||
return {
|
||||
kind: "exited" as const,
|
||||
targetLaunched: true,
|
||||
exitCode: null,
|
||||
output: "",
|
||||
targetIdentity: undefined,
|
||||
cleanupFailure:
|
||||
stopped.status === "incomplete" ? stopped.reason : undefined,
|
||||
...(stopped.status === "incomplete"
|
||||
? { cleanupOwner: supervisor }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
const client = createClient(tracer);
|
||||
|
||||
try {
|
||||
const trace = await tracer.trace(request);
|
||||
expect(trace.ok).toBe(false);
|
||||
if (trace.ok) throw new Error("Expected incomplete cleanup");
|
||||
expect(trace.error).toBeInstanceOf(ProviderCleanupError);
|
||||
if (!(trace.error instanceof ProviderCleanupError)) return;
|
||||
expect(trace.error.cleanupResources).toContain("lldb-process-group");
|
||||
expect(trace.error.partialObservation).toBeDefined();
|
||||
const root = runtimeRoot(trace.error);
|
||||
await expect(access(root)).resolves.toBeUndefined();
|
||||
if (ownedPid === undefined) throw new Error("Expected owned process PID");
|
||||
const pid = ownedPid;
|
||||
expect(() => process.kill(pid, 0)).not.toThrow();
|
||||
expect((await client.close()).ok).toBe(false);
|
||||
expect(() => process.kill(pid, 0)).not.toThrow();
|
||||
await expect(access(root)).resolves.toBeUndefined();
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
expect(() => process.kill(pid, 0)).toThrow();
|
||||
await expect(access(root)).rejects.toThrow();
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not retain a target when cancellation proves LLDB never launched",
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
const cancelledTracer = new LldbCallTracer(
|
||||
process.env,
|
||||
undefined,
|
||||
async () => {
|
||||
controller.abort();
|
||||
return ok(tool);
|
||||
},
|
||||
);
|
||||
const result = await cancelledTracer.trace(request, controller.signal);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancelled trace");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect((await cancelledTracer.close()).ok).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retains an unidentified debuggee and runtime until exit can be confirmed",
|
||||
async () => {
|
||||
const { child, pid } = await liveChild();
|
||||
const tracer = createTracer(async (_exe, _args, options) => {
|
||||
await writeFile(options.pidPath, String(pid));
|
||||
return {
|
||||
kind: "exited",
|
||||
targetLaunched: true,
|
||||
exitCode: 0,
|
||||
output: "",
|
||||
targetIdentity: undefined,
|
||||
cleanupFailure: undefined,
|
||||
};
|
||||
});
|
||||
const client = createClient(tracer);
|
||||
|
||||
try {
|
||||
const trace = await tracer.trace(request);
|
||||
expect(trace.ok).toBe(false);
|
||||
if (trace.ok) throw new Error("Expected unverified target cleanup");
|
||||
expect(trace.error).toBeInstanceOf(ProviderCleanupError);
|
||||
if (!(trace.error instanceof ProviderCleanupError)) return;
|
||||
expect(trace.error.cleanupResources).toContain(`native-target:${pid}`);
|
||||
const root = runtimeRoot(trace.error);
|
||||
await expect(access(root)).resolves.toBeUndefined();
|
||||
expect(() => process.kill(pid, 0)).not.toThrow();
|
||||
expect((await client.close()).ok).toBe(false);
|
||||
expect(() => process.kill(pid, 0)).not.toThrow();
|
||||
await expect(access(root)).resolves.toBeUndefined();
|
||||
await stopChild(child);
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
await expect(access(root)).rejects.toThrow();
|
||||
} finally {
|
||||
await stopChild(child);
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects malformed PID authority and retries after the target exits",
|
||||
async () => {
|
||||
const { child, pid } = await liveChild();
|
||||
const identity = await observeProcessStartIdentity(pid);
|
||||
if (identity?.state !== "readable")
|
||||
throw new Error("Host could not read fixture process identity");
|
||||
const tracer = createTracer(async (_exe, _args, options) => {
|
||||
await writeFile(options.pidPath, `${pid}junk`);
|
||||
return {
|
||||
kind: "exited",
|
||||
targetLaunched: true,
|
||||
exitCode: 0,
|
||||
output: "",
|
||||
targetIdentity: identity.identity,
|
||||
cleanupFailure: undefined,
|
||||
};
|
||||
});
|
||||
const client = createClient(tracer);
|
||||
|
||||
try {
|
||||
const trace = await tracer.trace(request);
|
||||
expect(trace.ok).toBe(false);
|
||||
if (trace.ok) throw new Error("Expected malformed PID cleanup failure");
|
||||
expect(trace.error).toBeInstanceOf(ProviderCleanupError);
|
||||
if (!(trace.error instanceof ProviderCleanupError)) return;
|
||||
expect(trace.error.cleanupResources).toContain("native-target:unknown");
|
||||
expect(() => process.kill(pid, 0)).not.toThrow();
|
||||
const root = runtimeRoot(trace.error);
|
||||
await stopChild(child);
|
||||
await writeFile(join(root, "pid"), String(pid));
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
} finally {
|
||||
await stopChild(child);
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -12,6 +12,12 @@ import type { NativeCallTracer } from "../../../../src/native/LldbCallTracer.js"
|
|||
import { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js";
|
||||
import { err, ok } from "../../../../src/domain/result.js";
|
||||
import { NativeCommandFailure } from "../../../../src/native/CommandRunner.js";
|
||||
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
|
||||
import { createDeferred } from "../../../fixtures/binarySession.js";
|
||||
import {
|
||||
NativeFixtureRunner,
|
||||
nativeMachoTarget,
|
||||
} from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
const EVENT = {
|
||||
sequence: 0,
|
||||
|
|
@ -83,11 +89,13 @@ const fixtureTracer = (
|
|||
return outcome === "cancelled"
|
||||
? {
|
||||
kind: "cancelled",
|
||||
targetLaunched: false,
|
||||
targetIdentity: undefined,
|
||||
cleanupFailure: undefined,
|
||||
}
|
||||
: {
|
||||
kind: "exited",
|
||||
targetLaunched: false,
|
||||
exitCode: 1,
|
||||
output: "bridge reported target integrity failure",
|
||||
targetIdentity: undefined,
|
||||
|
|
@ -108,6 +116,56 @@ const fixtureTracer = (
|
|||
};
|
||||
|
||||
describe("LLDB failure retention through the production tracer", () => {
|
||||
it("waits for an active trace before client close removes its runtime root", async () => {
|
||||
const launchGate = createDeferred<void>();
|
||||
const launchStarted = createDeferred<void>();
|
||||
const tracer = new LldbCallTracer(
|
||||
{},
|
||||
async (_executable, arguments_) => {
|
||||
const { config } = await configFromArguments(arguments_);
|
||||
await writeFile(
|
||||
config.observation_path,
|
||||
`${JSON.stringify({ kind: "event", event: EVENT })}\n`,
|
||||
);
|
||||
await writeFile(config.stdout_capture_path, "captured prefix");
|
||||
launchStarted.resolve();
|
||||
await launchGate.promise;
|
||||
return {
|
||||
kind: "exited",
|
||||
targetLaunched: false,
|
||||
exitCode: 0,
|
||||
output: "",
|
||||
targetIdentity: undefined,
|
||||
cleanupFailure: undefined,
|
||||
};
|
||||
},
|
||||
async () => ok(fakeTool),
|
||||
);
|
||||
const client = new NativeMacOSProvider(
|
||||
{},
|
||||
new NativeFixtureRunner(),
|
||||
"darwin",
|
||||
() => tracer,
|
||||
).createClient(nativeMachoTarget(request.executable));
|
||||
const tracePromise = tracer.trace(request);
|
||||
await launchStarted.promise;
|
||||
let closeFinished = false;
|
||||
const closePromise = client.close().then((result) => {
|
||||
closeFinished = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(closeFinished).toBe(false);
|
||||
launchGate.resolve();
|
||||
const [trace, close] = await Promise.all([tracePromise, closePromise]);
|
||||
expect(close.ok).toBe(true);
|
||||
if (trace.ok) throw new Error("expected missing LLDB result");
|
||||
expect(trace.error.partialObservation).toMatchObject({
|
||||
events: [EVENT],
|
||||
process: { stdout: { text: "captured prefix", complete: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it("names the missing LLDB requirement instead of asking for another target", async () => {
|
||||
const tracer = new LldbCallTracer(
|
||||
{},
|
||||
|
|
|
|||
|
|
@ -108,6 +108,10 @@ class FixtureTracer implements NativeCallTracer {
|
|||
this.requests.push(request);
|
||||
return Promise.resolve(this.outcome);
|
||||
}
|
||||
|
||||
close() {
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
}
|
||||
|
||||
const BREAKPOINTS = [
|
||||
|
|
@ -131,7 +135,7 @@ const observe = async (
|
|||
target?: Awaited<ReturnType<typeof fixtureTarget>>,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin", tracer)
|
||||
new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin", () => tracer)
|
||||
.createClient(target ?? (await fixtureTarget()))
|
||||
.execute(
|
||||
"observe_native_calls",
|
||||
|
|
@ -421,7 +425,7 @@ describe("observe_native_calls failures", () => {
|
|||
{},
|
||||
new NativeFixtureRunner(),
|
||||
"darwin",
|
||||
tracer,
|
||||
() => tracer,
|
||||
)
|
||||
.createClient({
|
||||
path: target.path,
|
||||
|
|
|
|||
Loading…
Reference in New Issue