Files
callstack__agent-device/src/request/cancel.ts
devin-ai-integration[bot] ea3813d3b3 fix(daemon): isolate disconnect cancellation and resource teardown (#1225)
* fix(daemon): isolate disconnect cancellation and resource teardown

Cancel HTTP requests that lose their client before response headers, and
scope disconnect cancellation to the affected request/device/session instead
of a global Apple runner abort. Make session resource teardown failure-isolated
so one rejected step no longer skips later cleanup, while preserving lease
release and session deletion.

Closes #1220

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(daemon,runner): request-scoped prep cancellation and platform-close error preservation

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(daemon): split session-close teardown to satisfy complexity gate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(daemon,runner): require pre-close runner stop and add integration prep-cancellation coverage

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(apple): preserve request cancellation during runner build

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(daemon): close request cancellation isolation gaps

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(request): keep cancellation cleanup owned

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-13 16:50:45 +02:00

136 lines
4.1 KiB
TypeScript

import { AppError } from '../kernel/errors.ts';
const canceledRequestIds = new Set<string>();
const requestAbortControllers = new Map<string, AbortController>();
const REQUEST_CANCELED_REASON = 'request_canceled';
const REQUEST_CANCELED_MESSAGE = 'request canceled';
const REQUEST_CANCELED_HINT =
'The request was canceled intentionally (explicit cancel or client disconnect) — no retry is needed unless the cancellation was unintended.';
export type RequestAbortRegistration = {
requestId: string;
controller: AbortController;
};
export function resolveRequestTrackingId(
requestId: string | undefined,
fallbackSeed?: unknown,
): string {
if (typeof requestId === 'string' && requestId.length > 0) return requestId;
const rawSeed =
typeof fallbackSeed === 'string'
? fallbackSeed
: typeof fallbackSeed === 'number' && Number.isFinite(fallbackSeed)
? String(fallbackSeed)
: 'generated';
const normalizedSeed =
rawSeed
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 32) || 'generated';
const nonce = Math.random().toString(36).slice(2, 10);
return `req:${normalizedSeed}:${process.pid}:${Date.now()}:${nonce}`;
}
const COLLECTION_HIGH_WATERMARK = 50_000;
const COLLECTION_EVICT_COUNT = 10_000;
function evictOldestEntries<K, V>(map: Map<K, V>): void {
if (map.size <= COLLECTION_HIGH_WATERMARK) return;
let removed = 0;
for (const key of map.keys()) {
if (removed >= COLLECTION_EVICT_COUNT) break;
map.delete(key);
removed++;
}
}
function evictOldestSetEntries<V>(set: Set<V>): void {
if (set.size <= COLLECTION_HIGH_WATERMARK) return;
let removed = 0;
for (const value of set) {
if (removed >= COLLECTION_EVICT_COUNT) break;
set.delete(value);
removed++;
}
}
export function registerRequestAbort(
requestId: string | undefined,
): RequestAbortRegistration | undefined {
if (!requestId) return undefined;
if (requestAbortControllers.has(requestId)) {
throw new AppError('INVALID_ARGS', `Request ID is already in flight: ${requestId}`, {
requestId,
reason: 'duplicate_request_id',
});
}
evictOldestEntries(requestAbortControllers);
const controller = new AbortController();
requestAbortControllers.set(requestId, controller);
if (canceledRequestIds.has(requestId)) {
controller.abort();
}
return { requestId, controller };
}
export function markRequestCanceled(requestId: string | undefined): void {
if (!requestId) return;
evictOldestSetEntries(canceledRequestIds);
canceledRequestIds.add(requestId);
requestAbortControllers.get(requestId)?.abort();
}
export function clearRequestCanceled(
requestId: string | undefined,
registration?: RequestAbortRegistration,
): void {
if (!requestId) return;
if (
registration &&
(registration.requestId !== requestId ||
requestAbortControllers.get(requestId) !== registration.controller)
) {
return;
}
canceledRequestIds.delete(requestId);
requestAbortControllers.delete(requestId);
}
export function clearRequestAbortRegistration(
registration: RequestAbortRegistration | undefined,
): void {
if (!registration) return;
clearRequestCanceled(registration.requestId, registration);
}
export function isRequestCanceled(requestId: string | undefined): boolean {
if (!requestId) return false;
return canceledRequestIds.has(requestId);
}
export function getRequestSignal(requestId: string | undefined): AbortSignal | undefined {
if (!requestId) return undefined;
return requestAbortControllers.get(requestId)?.signal;
}
export function createRequestCanceledError(): AppError {
return new AppError('COMMAND_FAILED', REQUEST_CANCELED_MESSAGE, {
reason: REQUEST_CANCELED_REASON,
hint: REQUEST_CANCELED_HINT,
});
}
export function throwIfRequestCanceled(requestId: string | undefined): void {
if (isRequestCanceled(requestId)) {
throw createRequestCanceledError();
}
}
export function isRequestCanceledError(error: unknown): boolean {
if (!(error instanceof AppError)) return false;
if (error.code !== 'COMMAND_FAILED') return false;
if (error.details?.reason === REQUEST_CANCELED_REASON) return true;
return error.message === REQUEST_CANCELED_MESSAGE;
}