fix(cancellation): retain caller reasons across digest and startup

This commit is contained in:
morluto committed 2026-10-25 07:31:31 +08:00
1 parent 9140deae63
commit f2fa951c00
5 files changed
+99 -20

No files matched your search

@@ -1057,7 +1057,11 @@ export const digestFile = async (
remainingMs,
);
const abort = (): void => {
stream.destroy(new AnalysisCancelledError("artifact_digest"));
stream.destroy(
new AnalysisCancelledError("artifact_digest", {
cause: signal?.reason,
}),
);
};
const cleanup = (): void => {
clearTimeout(timer);
@@ -1066,7 +1070,11 @@ export const digestFile = async (
if (signal?.aborted) {
cleanup();
stream.destroy();
rejectDigest(new AnalysisCancelledError("artifact_digest"));
rejectDigest(
new AnalysisCancelledError("artifact_digest", {
cause: signal.reason,
}),
);
return;
}
signal?.addEventListener("abort", abort, { once: true });
@@ -1139,7 +1147,9 @@ const statWithDeadline = (
timeoutMs: number,
): Promise<Stats> => {
if (signal?.aborted)
return Promise.reject(new AnalysisCancelledError("artifact_digest"));
return Promise.reject(
new AnalysisCancelledError("artifact_digest", { cause: signal.reason }),
);
return new Promise<Stats>((resolveStat, rejectStat) => {
let settled = false;
const finish = (outcome: Result<Stats, unknown>): void => {
@@ -1151,7 +1161,13 @@ const statWithDeadline = (
else rejectStat(outcome.error);
};
const abort = (): void =>
finish(err(new AnalysisCancelledError("artifact_digest")));
finish(
err(
new AnalysisCancelledError("artifact_digest", {
cause: signal?.reason,
}),
),
);
const timer = setTimeout(
() =>
finish(
+18 -9
View File
@@ -124,8 +124,12 @@ interface ServeInputs {
readonly served: ServedState;
}
const cancelledServe = (): JsonValue =>
cliError(new AnalysisCancelledError(CLI_COMMANDS.environmentServe));
const cancelledServe = (signal: AbortSignal): JsonValue =>
cliError(
new AnalysisCancelledError(CLI_COMMANDS.environmentServe, {
cause: signal.reason,
}),
);
/** Pure replay, as `--replay-only` asks for: no state is derived. */
const REPLAY_ONLY: ServedState = {
@@ -149,14 +153,14 @@ const loadServeInputs = async (
if (!evidence.ok) return evidence;
const { loadEnvironmentSource } =
await import("../application/environment/environmentSource.js");
if (signal.aborted) return err(cancelledServe());
if (signal.aborted) return err(cancelledServe(signal));
const source = loadEnvironmentSource(manifest.value, evidence.value);
if (!source.ok) return err(cliError(source.error));
if (input.replayOnly)
return ok({ source: source.value, served: REPLAY_ONLY });
const { prepareServedState } =
await import("../application/environment/environmentState.js");
if (signal.aborted) return err(cancelledServe());
if (signal.aborted) return err(cancelledServe(signal));
return ok({
source: source.value,
served: prepareServedState(source.value, evidence.value, {
@@ -172,7 +176,7 @@ const serveEnvironmentCommand = async (
logger: Logger,
signal: AbortSignal,
): Promise<JsonValue> => {
if (signal.aborted) return cancelledServe();
if (signal.aborted) return cancelledServe(signal);
const endpoint = parseMcpHttpEndpoint(environment, {
...(input.host === undefined ? {} : { host: input.host }),
...(input.port === undefined ? {} : { port: input.port }),
@@ -192,13 +196,13 @@ const serveEnvironmentCommand = async (
{ code: report.code, error: report.message },
"Environment state unavailable; serving recorded replay",
);
if (signal.aborted) return cancelledServe();
if (signal.aborted) return cancelledServe(signal);
// Loaded here so registering the command catalog never loads the HTTP listener.
const { serveEnvironment } =
await import("../server/environment/serveEnvironment.js");
const { StreamableHttpListenError } =
await import("../server/http/streamableHttpListenError.js");
if (signal.aborted) return cancelledServe();
if (signal.aborted) return cancelledServe(signal);
// A listener that fails after binding ends the command instead of serving nothing.
let failListener: (error: StreamableHttpListenError) => void = () =>
undefined;
@@ -221,7 +225,8 @@ const serveEnvironmentCommand = async (
if (!started.ok) return cliError(started.error);
served = started.value;
} catch (cause: unknown) {
if (signal.aborted && cause === signal.reason) return cancelledServe();
if (signal.aborted && cause === signal.reason)
return cancelledServe(signal);
if (!(cause instanceof StreamableHttpListenError)) throw cause;
return cliError(transportError(endpoint.value, cause));
}
@@ -449,7 +454,11 @@ const buildEnvironmentCommand = async (
capture = read.value;
}
if (signal.aborted)
return cliError(new AnalysisCancelledError(CLI_COMMANDS.environmentBuild));
return cliError(
new AnalysisCancelledError(CLI_COMMANDS.environmentBuild, {
cause: signal.reason,
}),
);
const captureInput: EnvironmentBuildCaptureInput = kind.value;
const built = await buildEnvironment({
capture,
+19 -5
View File
@@ -696,7 +696,11 @@ export class GdbSessionManager {
>
> {
if (signal?.aborted)
return err(new AnalysisCancelledError("start_gdb_session"));
return err(
new AnalysisCancelledError("start_gdb_session", {
cause: signal.reason,
}),
);
const occupied = this.#sessions.size + this.#sessionReservations;
if (occupied >= this.#maxSessions)
return err(
@@ -896,7 +900,11 @@ export class GdbSessionManager {
session.startupResolve = resolve;
session.startupReject = reject;
startupAbortListener = () =>
reject(new AnalysisCancelledError("start_gdb_session"));
reject(
new AnalysisCancelledError("start_gdb_session", {
cause: signal?.reason,
}),
);
signal?.addEventListener("abort", startupAbortListener, {
once: true,
});
@@ -923,7 +931,9 @@ export class GdbSessionManager {
startupTimer.unref();
});
if (signal?.aborted)
throw new AnalysisCancelledError("start_gdb_session");
throw new AnalysisCancelledError("start_gdb_session", {
cause: signal.reason,
});
const autoLoad = await this.#request(
session,
"-gdb-set auto-load off",
@@ -977,11 +987,15 @@ export class GdbSessionManager {
session.inferiorTtyVerified = true;
}
if (signal?.aborted)
throw new AnalysisCancelledError("start_gdb_session");
throw new AnalysisCancelledError("start_gdb_session", {
cause: signal.reason,
});
// Give queued exit notifications a turn before publishing startup.
await new Promise<void>((resolve) => setImmediate(resolve));
if (signal?.aborted)
throw new AnalysisCancelledError("start_gdb_session");
throw new AnalysisCancelledError("start_gdb_session", {
cause: signal.reason,
});
if (session.exited || session.protocolError !== undefined)
throw new Error(
session.protocolError ?? "GDB exited during startup readiness.",