diff --git a/docs/adr/0005-ios-runner-interaction-lifecycle.md b/docs/adr/0005-ios-runner-interaction-lifecycle.md index 486b8fb7e5..f0cc0101c2 100644 --- a/docs/adr/0005-ios-runner-interaction-lifecycle.md +++ b/docs/adr/0005-ios-runner-interaction-lifecycle.md @@ -136,7 +136,7 @@ For a command whose answer the daemon lost, that reading keeps the charge: recov An id the runner refused before dispatch is left free, which is what the read-only busy resend depends on. `status` answers `notAccepted` only for an id the journal does not hold. The daemon cannot read `notAccepted` as a safe terminal state, so evicting a running command would push it to invalidate a session whose runner is still executing that command. -The closed-id window holds 4096 ids, 64x the entry window, and it is a memory safety valve, not an admission boundary. Its residual bound, accepted here: within one runner process, a spent id is refused for the whole window, and the valve releases only the oldest id, so a resend arriving more than 4096 completed dispatches after a command can re-execute it. The resends this bound speaks of are lost-response resends of a dispatched command: a read-only `RUNNER_BUSY` resend reuses a `commandId` the runner refused before dispatch, and such an id never enters the window, so no chain of refusals can consume it. A runner restart starts a new journal, which answers `notAccepted` for every id; a replay across a runner replacement is governed by charge settlement, not by this window. +The closed-id window holds 4096 ids, 64x the entry window, and it is a memory safety valve, not an admission boundary. Its residual bound, accepted here: within one runner process, a spent id is refused for the whole window, and the valve releases only the oldest id, so a resend arriving more than 4096 completed dispatches after a command can re-execute it. The resends this bound speaks of are lost-response resends of a dispatched command: a read-only `RUNNER_BUSY` resend reuses a `commandId` the runner refused before dispatch, and such an id never enters the window, so no chain of refusals can consume it. A runner restart starts a new journal, which answers `notAccepted` for every id. After a runner replacement, replay a mutating command only when dispatch classification proves `dispatched: no`. `RunnerCommandAccounting` controls runner handoff. A watermark over issued ids was rejected: the daemon mints `commandId` as a random UUID per request, reuses one id across retries and across runner generations, and persists nothing, so no ordering exists to compare. Eviction on a daemon resend deadline was rejected: the daemon holds no per-id "never resend after this instant" value — its resend budgets are attempt counts and per-exchange timeouts redrawn per attempt, and a restart-and-replay can legitimately post the same id minutes later — so a deadline-based rule would need a wire-contract change this change does not make. diff --git a/packages/host-kit/src/internal/request-cancel.test.ts b/packages/host-kit/src/internal/request-cancel.test.ts index df53df58d6..2f4d38b031 100644 --- a/packages/host-kit/src/internal/request-cancel.test.ts +++ b/packages/host-kit/src/internal/request-cancel.test.ts @@ -8,6 +8,7 @@ import { } from '@agent-device/kernel/errors'; import { clearRequestAbortRegistration, + clearRequestCanceled, markRequestCanceled, registerRequestAbort, resolveRequestTrackingId, @@ -16,6 +17,25 @@ import { withRequestDeadline, } from './request-cancel.ts'; +test('an early live request remains cancelable after more than 50,000 registrations', () => { + const requestId = 'early-live-request'; + const registration = registerRequestAbort(requestId)!; + try { + for (let index = 0; index < 50_001; index++) { + registerRequestAbort(`later-live-request-${index}`); + } + markRequestCanceled(requestId); + assert.equal(registration.controller.signal.aborted, true); + assert.equal(isRequestCanceledError(registration.controller.signal.reason), true); + assert.equal(getRequestSignal(requestId), registration.controller.signal); + } finally { + clearRequestCanceled(requestId); + for (let index = 0; index < 50_001; index++) { + clearRequestCanceled(`later-live-request-${index}`); + } + } +}); + test('resolveRequestTrackingId generates unique ids for fallback seeds', () => { const first = resolveRequestTrackingId(undefined, 42); const second = resolveRequestTrackingId(undefined, 42); diff --git a/packages/host-kit/src/internal/request-cancel.ts b/packages/host-kit/src/internal/request-cancel.ts index 2abf1ecebc..4b4b98d866 100644 --- a/packages/host-kit/src/internal/request-cancel.ts +++ b/packages/host-kit/src/internal/request-cancel.ts @@ -65,16 +65,6 @@ export function resolveRequestTrackingId( const COLLECTION_HIGH_WATERMARK = 50_000; const COLLECTION_EVICT_COUNT = 10_000; -function evictOldestEntries(map: Map): 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(set: Set): void { if (set.size <= COLLECTION_HIGH_WATERMARK) return; let removed = 0; @@ -97,7 +87,6 @@ export function registerRequestAbort( reason: 'duplicate_request_id', }); } - evictOldestEntries(requestAbortControllers()); const controller: RequestAbortController = new AbortController(); const parent = parentRequestId ? requestAbortControllers().get(parentRequestId) : undefined; controller.executionDeadlineMs = parent?.executionDeadlineMs;