Files
rea/src/native/pwntools/PwntoolsDecoder.ts
T
morluto da526fc014 fix(providers): preserve reply bytes and structured JSON ownership
Share strict UTF-8 JSON admission across bounded worker reports. Preserve IDA/JEB structured dictionaries through negotiated SDK validation and adapter decoding, retaining inherited tool validation and protocol handling.

Validated 240 tests, including real SDK/HTTP exchanges and firmware file reads; 83 host-specific cases skipped. Typecheck, lint, and discovery passed. Real-provider execution and modern MCP protocol behavior remain unverified.
2026-10-26 17:52:32 +08:00

800 lines
28 KiB
TypeScript

import { safeParseUtf8Json } from "../../domain/safeJson.js";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../../artifacts/AnalysisCleanupLease.js";
import {
bindArtifactFileCleanup,
artifactFileCancellationCause,
} from "../../artifacts/ArtifactFileOperation.js";
import {
pwntoolsDecoderFailure,
pwntoolsUnavailable,
capturedPwntoolsOutput,
} from "./PwntoolsFailures.js";
import { randomUUID } from "node:crypto";
import { access, stat, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import type {
ExecutionOptions,
ProviderIdentity,
} from "../../application/AnalysisProvider.js";
import type {
AnalysisCapturedOutput,
AnalysisPartialObservation,
} from "../../domain/analysisErrorBase.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisUnsupportedTargetError,
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
ownedCommandResources,
runOwnedCommand,
} from "../../process/OwnedCommand.js";
import type {
ProviderProcessSupervisor,
OwnedProviderProcessSpawnOptions,
ProviderProcessSnapshot,
} from "../../process/ProviderProcess.js";
import { PendingOperations } from "../../process/PendingOperations.js";
import {
pwntoolsResourceLimitsSchema,
readPwntoolsFailureEvidence,
} from "./PwntoolsResourceLimits.js";
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
import {
PWNTOOLS_PROVIDER_IDENTITY,
PWNTOOLS_LIMITS,
} from "./PwntoolsRelease.js";
import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js";
const HOST_REQUIREMENT =
"The initial real-verified pwntools artifact profile supports Linux x64 only.";
/** The pwntools profile runs only on Linux x64; discovery and execution share this check. */
export const pwntoolsHostSupported = (
platform: NodeJS.Platform,
arch: string,
): boolean => platform === "linux" && arch === "x64";
/** Owned launcher seam shared by the offline pwntools artifact adapters. */
export type PwntoolsLauncher = NonNullable<
Parameters<typeof runOwnedCommand>[2]
>["launcher"];
/** Owner-bound execution; replies and failure files are stable only after this settles without cleanup uncertainty. */
export type PwntoolsCommand = (
spawn: OwnedProviderProcessSpawnOptions,
/** Project the original failure without reading files an unresolved worker may change. */
failure: (cause: unknown) => AnalysisError,
) => Promise<ProviderProcessSnapshot>;
type DecoderReport = AnalysisPartialObservation & {
readonly diagnostics: AnalysisCapturedOutput;
};
/** Adapter-selected semantics around a shared owned pwntools process boundary. */
export interface PwntoolsDecoderProfile<
Input extends {
readonly path: string;
readonly pwntools_timeout_ms?: number | undefined;
},
Payload,
Report extends DecoderReport,
> {
readonly identity: ProviderIdentity;
readonly operation: string;
readonly bridge: URL;
readonly payloadSchema: z.ZodType<Payload>;
readonly project: (context: {
readonly input: Input;
readonly payload: Payload;
readonly snapshot: { readonly bytes: Buffer; readonly sha256: string };
readonly rootPath: string;
readonly resources: ArtifactResourceScope;
readonly diagnostics: AnalysisCapturedOutput;
readonly options: ExecutionOptions | undefined;
readonly runCommand: PwntoolsCommand;
/** Effective `pwntools_timeout_ms`, including the omitted default. */
readonly timeoutMs: number;
}) => Report | Promise<Report>;
}
/** Thin owned-process adapter; all ELF parsing and heuristics come from unchanged upstream code. */
export class PwntoolsDecoder<
Input extends {
readonly path: string;
readonly pwntools_timeout_ms?: number | undefined;
},
Payload,
Report extends DecoderReport,
> {
readonly identity: ProviderIdentity;
#tail: Promise<void> = Promise.resolve();
readonly #admissions = new PendingOperations<
symbol,
"admitted" | "cancelled"
>();
readonly #resources = new ArtifactResourceScope();
#closing = false;
#closePromise: Promise<void> | undefined;
#pendingCleanup:
| {
root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
rootLease: AnalysisCleanupLease;
process:
| {
readonly supervisor: ProviderProcessSupervisor;
readonly lease: AnalysisCleanupLease;
}
| undefined;
failure: AnalysisError | undefined;
diagnostics: AnalysisCapturedOutput | undefined;
observation: AnalysisPartialObservation | undefined;
}
| undefined;
constructor(
readonly profile: PwntoolsDecoderProfile<Input, Payload, Report>,
readonly environment: Readonly<NodeJS.ProcessEnv>,
readonly launcher?: PwntoolsLauncher,
readonly createRuntime: () => Promise<
Pick<PrivateRuntimeRoot, "path" | "close">
> = () => PrivateRuntimeRoot.create({ prefix: "rea-artifact-decoder-" }),
) {
this.identity = profile.identity;
}
/** Snapshot one explicit artifact and return complete static observations without launching the selected object as a host process. */
inspect(
input: Input,
options?: ExecutionOptions,
): Promise<Result<Report, AnalysisError>> {
return this.#enqueue(() => this.#inspect(input, options), options);
}
/** Finalize an admitted inspection before publishing its outcome; rejected admission acquires nothing and does not close the provider. */
inspectAndClose(
input: Input,
options?: ExecutionOptions,
): Promise<Result<Report, AnalysisError>> {
return this.#enqueue(() => {
this.#closing = true;
return this.#inspect(input, options);
}, options);
}
#enqueue(
inspect: () => Promise<Result<Report, AnalysisError>>,
options?: ExecutionOptions,
): Promise<Result<Report, AnalysisError>> {
if (options?.signal?.aborted)
return Promise.resolve(
err(
new AnalysisCancelledError(this.profile.operation, {
cause: options?.signal?.reason,
}),
),
);
if (this.#closing) return Promise.resolve(err(this.#closedError()));
const key = Symbol();
const admission = this.#admissions.wait(key, {
...(options?.signal === undefined ? {} : { signal: options.signal }),
cancelledValue: () => "cancelled",
});
const operation = this.#tail.then(() => {
this.#admissions.settle(key, "admitted");
if (options?.signal?.aborted)
return err(
new AnalysisCancelledError(this.profile.operation, {
cause: options?.signal?.reason,
}),
);
return this.#closing ? err(this.#closedError()) : inspect();
});
this.#tail = operation.then(
() => undefined,
() => undefined,
);
return admission.then((state) =>
state === "cancelled"
? err(
new AnalysisCancelledError(this.profile.operation, {
cause: options?.signal?.reason,
}),
)
: operation,
);
}
async #inspect(
input: Input,
options?: ExecutionOptions,
): Promise<Result<Report, AnalysisError>> {
const pending = await this.#retire();
if (pending !== undefined) return err(pending);
const OPERATION = this.profile.operation;
const timeoutMs = input.pwntools_timeout_ms ?? PWNTOOLS_LIMITS.timeoutMs;
const context = {
operation: OPERATION,
providerId: this.identity.id,
timeoutMs,
};
const replySchema = z.discriminatedUnion("ok", [
z.strictObject({
ok: z.literal(true),
profile: z.literal(PWNTOOLS_PROVIDER_IDENTITY.version),
value: this.profile.payloadSchema,
}),
z.strictObject({
ok: z.literal(false),
reason: z.enum([
"format",
"unsupported",
"unsupported-target",
"unavailable",
"resource-limit",
"output-limit",
"decoder",
]),
message: z.string(),
reported_limits: pwntoolsResourceLimitsSchema.nullable().optional(),
}),
]);
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
let result: Result<Report, AnalysisError>;
let phase: "configuration" | "artifact-read" | "decoder" = "configuration";
const executablePath = this.environment.REA_PWNTOOLS_PYTHON ?? "";
let selectedPath = executablePath;
const failure = (cause: unknown) =>
pwntoolsDecoderFailure(
cause,
phase,
selectedPath,
executablePath,
{},
context,
);
try {
if (options?.signal?.aborted)
throw new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
});
if (!pwntoolsHostSupported(process.platform, process.arch))
throw new AnalysisCapabilityUnavailableError(
this.identity.id,
OPERATION,
HOST_REQUIREMENT,
{ userMessage: HOST_REQUIREMENT },
);
if (!isAbsolute(selectedPath))
throw pwntoolsUnavailable(
"Set REA_PWNTOOLS_PYTHON to an absolute caller-supplied Python executable with pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2; REA never installs Python or packages.",
selectedPath,
undefined,
undefined,
context,
);
await access(selectedPath, constants.X_OK);
if (!(await stat(selectedPath)).isFile())
throw pwntoolsUnavailable(
"Selected Python executable must be a regular executable file, not a directory: " +
selectedPath,
selectedPath,
undefined,
undefined,
context,
);
const inputLimit = pwntoolsInputBytes(this.environment);
if (!inputLimit.configured)
throw pwntoolsUnavailable(
inputLimit.error,
selectedPath,
undefined,
undefined,
{
...context,
configurationKey: "REA_PWNTOOLS_MAX_INPUT_BYTES",
},
);
phase = "artifact-read";
selectedPath = input.path;
const snapshot = await readStableArtifact(
input.path,
inputLimit.bytes,
options?.signal,
undefined,
this.#resources,
);
phase = "decoder";
root = await this.createRuntime();
this.#pendingCleanup = {
root,
rootLease: AnalysisCleanupLease.forCloseOwner(root, root.path),
process: undefined,
failure: undefined,
diagnostics: undefined,
observation: undefined,
};
const requestPath = join(root.path, "request.json");
const snapshotPath = join(root.path, "object.snapshot");
const replyPath = join(root.path, "reply.json");
try {
await writeFile(snapshotPath, snapshot.bytes, {
flag: "wx",
mode: 0o600,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
} catch (cause: unknown) {
// Normalize Node's abort envelope only at the write that produced it.
if (
options?.signal?.aborted &&
cause instanceof Error &&
"code" in cause &&
cause.code === "ABORT_ERR" &&
cause.cause === options.signal.reason
)
throw new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
});
throw cause;
}
await writeFile(
requestPath,
JSON.stringify({ snapshot_path: snapshotPath, reply_path: replyPath }),
{ flag: "wx", mode: 0o600 },
);
const execution = await this.#runCommand(
timeoutMs,
{
command: this.environment.REA_PWNTOOLS_PYTHON ?? "",
arguments: [
"-I",
fileURLToPath(this.profile.bridge),
join(root.path, "resource.failure"),
requestPath,
],
cwd: root.path,
runId: `rea-artifact-decoder-${randomUUID()}`,
hostEnvironment: {
...this.environment,
PWNLIB_NOTERM: "1",
OPENBLAS_NUM_THREADS: "1",
OMP_NUM_THREADS: "1",
},
},
options,
failure,
);
const capturedOutput = capturedPwntoolsOutput(execution);
let reply: z.output<typeof replySchema>;
try {
const file = await readStableArtifact(
replyPath,
PWNTOOLS_LIMITS.outputBytes,
options?.signal,
undefined,
this.#resources,
);
const decoded = safeParseUtf8Json(file.bytes);
if (!decoded.ok) throw decoded.cause;
reply = replySchema.parse(decoded.value);
} catch (cause: unknown) {
if (
cause instanceof ArtifactReaderFailure &&
cause.reason === "cancelled"
) {
const error = new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
capturedOutput,
...(cause instanceof ArtifactReaderFailure &&
cause.cleanup !== undefined
? { cleanup: cause.cleanup }
: {}),
});
bindArtifactFileCleanup(cause, error);
throw error;
}
const error = new AnalysisOutputError(
OPERATION,
`Owned pwntools decoder reply failed for ${input.path}: ${cause instanceof z.ZodError ? cause.issues[0]?.message + " at " + (cause.issues[0]?.path.map(String).join(".") ?? "root") : cause instanceof Error ? cause.message : String(cause)}`,
{
cause,
capturedOutput,
...(cause instanceof ArtifactReaderFailure
? { cleanup: cause.cleanup }
: {}),
},
);
bindArtifactFileCleanup(cause, error);
throw error;
}
if (!reply.ok) {
if (reply.reason === "unsupported-target")
throw new AnalysisUnsupportedTargetError(
OPERATION,
input.path,
reply.message,
{ capturedOutput },
);
if (reply.reason === "resource-limit")
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
`${reply.message} cpuSeconds is ${String(PWNTOOLS_LIMITS.cpuSeconds)}. Output is ${String(PWNTOOLS_LIMITS.outputBytes)} bytes, diagnostics are ${String(PWNTOOLS_LIMITS.diagnosticBytes)} bytes, and address space is ${String(PWNTOOLS_LIMITS.addressSpaceBytes)} bytes. This release has no runtime override.`,
reply.reported_limits ?? null,
{
capturedOutput,
remediationAction: `Review the reported worker memory limits and available host memory. cpuSeconds ${String(PWNTOOLS_LIMITS.cpuSeconds)}, the ${String(PWNTOOLS_LIMITS.outputBytes)}-byte output allowance, the ${String(PWNTOOLS_LIMITS.diagnosticBytes)}-byte diagnostic allowance, and the ${String(PWNTOOLS_LIMITS.addressSpaceBytes)}-byte address-space allowance have no runtime override. pwntools_timeout_ms does not raise them. Repeating the unchanged request fails the same way.`,
},
);
if (reply.reason === "format")
throw new AnalysisInputError(OPERATION, { capturedOutput }, [
{
path: ["path"],
reason: "invalid_format",
message: reply.message,
},
]);
if (reply.reason === "unavailable")
throw pwntoolsUnavailable(
reply.message,
this.environment.REA_PWNTOOLS_PYTHON ?? "",
undefined,
capturedOutput,
context,
);
if (reply.reason === "unsupported")
throw new AnalysisCapabilityUnavailableError(
this.identity.id,
OPERATION,
reply.message,
{ userMessage: reply.message, capturedOutput },
);
if (reply.reason === "output-limit")
throw new AnalysisOutputError(OPERATION, reply.message, {
capturedOutput,
});
throw new ProviderAdapterError(this.identity.id, OPERATION, {
diagnostics: {
phase,
failure_kind: reply.reason,
reason: reply.message,
stdout: execution.stdout.text,
stderr: execution.stderr.text,
captured_output: { ...capturedOutput },
},
});
}
result = ok(
await this.profile.project({
input,
payload: reply.value,
snapshot,
rootPath: root.path,
resources: this.#resources,
diagnostics: capturedOutput,
options,
runCommand: (spawn, classifyFailure) =>
this.#runCommand(timeoutMs, spawn, options, classifyFailure),
timeoutMs,
}),
);
} catch (cause: unknown) {
// The command owner has exhausted this operation's cleanup attempts.
// Keep its resources and do not consume files an unresolved worker may change.
if (cause instanceof AnalysisError && cause.cleanupIncomplete) {
if (this.#pendingCleanup !== undefined) {
this.#pendingCleanup.failure = cause;
this.#pendingCleanup.observation = cause.partialObservation;
}
return err(cause);
}
const evidence = await readPwntoolsFailureEvidence(
cause,
root?.path,
this.#resources,
);
const primary = pwntoolsDecoderFailure(
cause,
phase,
selectedPath,
executablePath,
evidence,
context,
);
result = err(primary);
}
if (this.#pendingCleanup !== undefined) {
this.#pendingCleanup.failure = result.ok ? undefined : result.error;
this.#pendingCleanup.observation = result.ok
? result.value
: result.error.partialObservation;
this.#pendingCleanup.diagnostics = result.ok
? result.value.diagnostics
: result.error.capturedOutput;
}
const cleanup = await this.#completeCleanup(() => this.#retire());
if (result.ok && options?.signal?.aborted)
result = err(
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
partialObservation: result.value,
capturedOutput: result.value.diagnostics,
}),
);
if (cleanup !== undefined) {
const failure = result.ok
? cleanup
: analysisErrorWithCleanupFailure(
result.error,
cleanup,
OPERATION,
).bindCleanupDependencies([result.error, cleanup]);
if (this.#pendingCleanup !== undefined)
this.#pendingCleanup.failure = failure;
return err(failure);
}
return result;
}
async #runCommand(
timeoutMs: number,
spawn: OwnedProviderProcessSpawnOptions,
options: ExecutionOptions | undefined,
failure: (cause: unknown) => AnalysisError,
): Promise<ProviderProcessSnapshot> {
// A runtime is registered before either decoder stage may acquire a process.
const pending = this.#pendingCleanup;
if (pending === undefined)
throw new Error("Decoder command requires an owned runtime.");
let outcome: Result<ProviderProcessSnapshot, unknown>;
try {
outcome = ok(
await runOwnedCommand(
{
...spawn,
// Every decoder stage can replace its launcher through exec,
// including the optional Python-to-GDB handoff. Preserve explicit
// pins; otherwise keep parent/group/run-token proof across exec.
expectedCommand: spawn.expectedCommand ?? null,
},
{
timeoutMs,
diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes,
},
{
...(options?.signal === undefined
? {}
: { signal: options.signal }),
...(this.launcher === undefined ? {} : { launcher: this.launcher }),
retainCleanup: (supervisor) => {
const ownership = supervisor.launch.ownership;
pending.process = {
supervisor,
lease: new AnalysisCleanupLease(
async () => {
const stopped = await supervisor.stop();
if (stopped.status === "incomplete")
throw new Error(stopped.reason, { cause: stopped });
},
ownership === undefined
? ["owned-decoder"]
: ownedCommandResources(ownership),
),
};
},
},
),
);
} catch (cause: unknown) {
outcome = err(cause);
}
const cleanup = await this.#completeCleanup(() => this.#retireProcess());
if (cleanup !== undefined) {
if (outcome.ok) throw cleanup;
// Until retirement succeeds, resource-limit markers remain unverified.
const primary = failure(outcome.error);
throw analysisErrorWithCleanupFailure(
primary,
cleanup,
this.profile.operation,
).bindCleanupDependencies([primary, cleanup]);
}
if (!outcome.ok) throw outcome.error;
return outcome.value;
}
/** Drain the running inspection, reject queued acquisition, and retry retained processes before deleting their files. */
close(): Promise<void> {
this.#closing = true;
this.#closePromise ??= this.#tail
.then(async () => {
let failure: AnalysisError | undefined;
try {
failure = await this.#retire();
} catch (cause: unknown) {
failure = new ProviderCleanupError(
this.identity.id,
[this.#pendingCleanup?.root?.path ?? "pwntools decoder"],
{ reason: cause instanceof Error ? cause.message : String(cause) },
{ operation: this.profile.operation, cause },
);
}
try {
await this.#resources.close();
} catch (cause: unknown) {
const observation = ArtifactReaderFailure.cleanupObservation(
cause,
"pwntools artifact read",
);
const resourceFailure = new ProviderCleanupError(
this.identity.id,
observation.resources,
{ reason: observation.reason },
{ operation: this.profile.operation, cause },
);
bindArtifactFileCleanup(cause, resourceFailure);
failure =
failure === undefined
? resourceFailure
: analysisErrorWithCleanupFailure(
failure,
resourceFailure,
this.profile.operation,
).bindCleanupDependencies([failure, resourceFailure]);
}
if (failure !== undefined) throw failure;
})
.catch((cause: unknown) => {
this.#closePromise = undefined;
throw cause;
});
return this.#closePromise;
}
#closedError(): AnalysisCapabilityUnavailableError {
return new AnalysisCapabilityUnavailableError(
this.identity.id,
this.profile.operation,
"Provider is closing; create a new inspection service.",
);
}
async #completeCleanup(
retire: () => Promise<AnalysisError | undefined>,
): Promise<AnalysisError | undefined> {
const failure = await retire();
return failure !== undefined && this.#closing ? retire() : failure;
}
async #retireProcess(): Promise<ProviderCleanupError | undefined> {
const pending = this.#pendingCleanup;
if (pending?.process === undefined) return undefined;
const process = pending.process;
const ownership = process.supervisor.launch.ownership;
try {
await process.lease.release();
} catch (cause: unknown) {
const failure = new ProviderCleanupError(
this.identity.id,
ownership === undefined
? [
"owned-decoder",
...(pending.root === undefined ? [] : [pending.root.path]),
]
: ownedCommandResources(ownership, pending.root?.path),
{
reason: cause instanceof Error ? cause.message : String(cause),
captured_output: {
...(pending.diagnostics ??
capturedPwntoolsOutput(process.supervisor.snapshot())),
},
previous_error:
pending.failure === undefined
? null
: projectAnalysisError(pending.failure),
},
{
operation: this.profile.operation,
cause,
...(pending.observation === undefined
? {}
: { partialObservation: pending.observation }),
},
);
new AnalysisCleanupLeaseGroup(failure, [
process.lease,
pending.rootLease,
]);
return failure;
}
pending.process = undefined;
return undefined;
}
async #retire(): Promise<AnalysisError | undefined> {
let failure: AnalysisError | undefined;
try {
await this.#resources.retryCleanup();
} catch (cause: unknown) {
const observation = ArtifactReaderFailure.cleanupObservation(
cause,
"pwntools artifact read",
);
failure = new ProviderCleanupError(
this.identity.id,
observation.resources,
{ reason: observation.reason },
{
operation: this.profile.operation,
cause,
...(this.#pendingCleanup?.observation === undefined
? {}
: { partialObservation: this.#pendingCleanup.observation }),
},
);
}
if (failure !== undefined) bindArtifactFileCleanup(failure.cause, failure);
const processFailure = await this.#retireProcess();
if (processFailure !== undefined) {
return failure === undefined
? processFailure
: analysisErrorWithCleanupFailure(
failure,
processFailure,
this.profile.operation,
).bindCleanupDependencies([failure, processFailure]);
}
// Retained descriptors still own local evidence under this root.
if (failure !== undefined) return failure;
const pending = this.#pendingCleanup;
if (pending?.root !== undefined) {
const root = pending.root;
try {
await pending.rootLease.release();
pending.root = undefined;
} catch (cause: unknown) {
const rootFailure = new ProviderCleanupError(
this.identity.id,
[root.path],
{
reason: cause instanceof Error ? cause.message : String(cause),
...(pending.diagnostics === undefined
? {}
: { captured_output: { ...pending.diagnostics } }),
previous_error:
pending.failure === undefined
? null
: projectAnalysisError(pending.failure),
},
{
operation: this.profile.operation,
cause,
...(pending.observation === undefined
? {}
: { partialObservation: pending.observation }),
},
);
new AnalysisCleanupLeaseGroup(rootFailure, [pending.rootLease]);
failure = rootFailure;
}
}
if (!this.#resources.hasPending && pending?.root === undefined)
this.#pendingCleanup = undefined;
return failure;
}
}