@bitkyc08/opencodex 2.55.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
cleanupSupersededResponseSpillPublication,
|
|
4
|
+
createResponseSpillPublicationControl,
|
|
5
|
+
deleteResponseSpill,
|
|
6
|
+
markResponseSpillPublicationSuperseded,
|
|
7
|
+
MAX_RESPONSE_SPILL_PAYLOAD_BYTES,
|
|
8
|
+
prospectiveResponseSpillBytes,
|
|
9
|
+
responseSpillPayloadCap,
|
|
10
|
+
type ResponseSpillPublicationControl,
|
|
11
|
+
type ResponseSpillRef,
|
|
12
|
+
writeResponseSpillDurably,
|
|
13
|
+
writeResponseSpillDurablyAsync,
|
|
14
|
+
} from "../spill-store";
|
|
15
|
+
import { enforceAppOwnedMemoryBudget } from "../../lib/app-owned-memory";
|
|
16
|
+
import {
|
|
17
|
+
admissionCounters,
|
|
18
|
+
noteSpillWriteFailure,
|
|
19
|
+
noteSpillWriteSuccess,
|
|
20
|
+
spillAclMemoRefusalOrigin,
|
|
21
|
+
type ResponseSpillWriteFailureCode,
|
|
22
|
+
type ResponseSpillWriteFailureOrigin,
|
|
23
|
+
} from "./spill-failure";
|
|
24
|
+
import type { ResidentResponseState, StoredResponseState } from "../state";
|
|
25
|
+
|
|
26
|
+
const RESPONSE_SPILL_SHUTDOWN_BUDGET_MS = 5_000;
|
|
27
|
+
const RESPONSE_SPILL_SHUTDOWN_FALLBACK_RESERVE_MS = 4_000;
|
|
28
|
+
const RESPONSE_SPILL_ASYNC_ACL_ATTEMPT_BUDGET_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
export interface SpillQueueStore {
|
|
31
|
+
swapResidentForSpill(id: string, expected: ResidentResponseState, ref: ResponseSpillRef): boolean;
|
|
32
|
+
replaceWithSpillFailure(id: string, expected?: StoredResponseState, options?: { deferSpillUnlink?: boolean }): void;
|
|
33
|
+
deleteEntry(id: string, options?: { deleteSpill?: boolean }): void;
|
|
34
|
+
deferSupersededSpill(ref: ResponseSpillRef | undefined): void;
|
|
35
|
+
replaceMapEntry(id: string, next: StoredResponseState, expected?: StoredResponseState): boolean;
|
|
36
|
+
currentEntry(id: string): StoredResponseState | undefined;
|
|
37
|
+
residentEntries(): Array<[string, StoredResponseState]>;
|
|
38
|
+
recomputeOldestResident(): void;
|
|
39
|
+
schedulePersist(): void;
|
|
40
|
+
pruneResponses(): void;
|
|
41
|
+
accountedResponseSpillBytes(): number;
|
|
42
|
+
spillByteCap(): number;
|
|
43
|
+
enforceSpilledResponseBudget(): number;
|
|
44
|
+
terminalizationMaxPasses(): number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let store: SpillQueueStore | null = null;
|
|
48
|
+
|
|
49
|
+
export function bindSpillQueueStore(next: SpillQueueStore): void {
|
|
50
|
+
store = next;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function requireStore(): SpillQueueStore {
|
|
54
|
+
if (!store) throw new Error("spill-queue store is not bound");
|
|
55
|
+
return store;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Windows keeps the candidate replayable while required ACL hardening runs off the event loop.
|
|
60
|
+
* Pending bytes are pinned, not evictable; cap them below the process-owned 512 MiB ceiling so an
|
|
61
|
+
* icacls outage cannot turn the serialized queue into an unbounded resident backlog.
|
|
62
|
+
*/
|
|
63
|
+
const MAX_PENDING_RESPONSE_SPILL_BYTES = MAX_RESPONSE_SPILL_PAYLOAD_BYTES;
|
|
64
|
+
|
|
65
|
+
interface PendingResponseSpill {
|
|
66
|
+
id: string;
|
|
67
|
+
candidate: ResidentResponseState | null;
|
|
68
|
+
supersededSpill?: ResponseSpillRef;
|
|
69
|
+
directAdmission: boolean;
|
|
70
|
+
running: boolean;
|
|
71
|
+
cancelled: boolean;
|
|
72
|
+
released: boolean;
|
|
73
|
+
sizeBytes: number;
|
|
74
|
+
/** Peak on-disk bytes reserved for this publication; released exactly once on settle. */
|
|
75
|
+
reservedBytes: number;
|
|
76
|
+
publicationControl: ResponseSpillPublicationControl;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pendingResponseSpills = new Set<PendingResponseSpill>();
|
|
80
|
+
const pendingResponseSpillById = new Map<string, PendingResponseSpill>();
|
|
81
|
+
let pendingResponseSpillBytes = 0;
|
|
82
|
+
/**
|
|
83
|
+
* On-disk bytes a queued publication is about to occupy but has not yet installed into
|
|
84
|
+
* `states`.
|
|
85
|
+
*
|
|
86
|
+
* `spilledResponseBytes()` walks installed spills and deferred unlinks — files that
|
|
87
|
+
* already exist. It cannot see one that `writeResponseSpillDurablyAsync` is in the
|
|
88
|
+
* middle of creating, and on Windows that middle can last as long as `icacls` takes.
|
|
89
|
+
* Without a reservation the cap holds only when writes are fast, which is not a cap.
|
|
90
|
+
*
|
|
91
|
+
* The reserved figure is the PEAK footprint, not the payload: publication can fall back
|
|
92
|
+
* from hard-linking to an exclusive copy, and during that fallback the destination copy
|
|
93
|
+
* and the temp file exist simultaneously. Reserving one envelope would leave the overshoot
|
|
94
|
+
* intact at half its magnitude.
|
|
95
|
+
*
|
|
96
|
+
* Ownership is single: a job holds its reservation from queue until
|
|
97
|
+
* `releasePendingResponseSpill`, which every exit from the publication path reaches
|
|
98
|
+
* through the `finally` in `runPendingResponseSpill` and through cancellation of a
|
|
99
|
+
* not-yet-running job. A leaked reservation is monotonic — it would ratchet the usable
|
|
100
|
+
* cap toward zero — so the release must stay on the settlement path rather than in a
|
|
101
|
+
* parallel bookkeeping pass.
|
|
102
|
+
*/
|
|
103
|
+
let reservedResponseSpillBytes = 0;
|
|
104
|
+
/**
|
|
105
|
+
* Paths a failed cleanup left on the volume, with the bytes each one occupies.
|
|
106
|
+
*
|
|
107
|
+
* A failed unlink leaves a real file behind, so the cap has to keep seeing it. But a
|
|
108
|
+
* never-decremented total would be phantom debt: a Windows lock that clears a moment
|
|
109
|
+
* later, or the async writer's own retry, can remove the file while the charge stays
|
|
110
|
+
* forever — and with 256 MiB payloads two conservative charges consume the whole default
|
|
111
|
+
* cap, after which nothing can spill for the life of the process.
|
|
112
|
+
*
|
|
113
|
+
* So the debt is per PATH, priced at what that path actually holds, and settled the
|
|
114
|
+
* moment the path is gone. `reconcileUnreclaimableSpillPaths` re-checks on every read of
|
|
115
|
+
* the accounted total, which is the same tick that would otherwise refuse an admission.
|
|
116
|
+
*/
|
|
117
|
+
const unreclaimableSpillPaths = new Map<string, number>();
|
|
118
|
+
|
|
119
|
+
function chargeUnreclaimableSpillPath(path: string | null | undefined, bytes: number): void {
|
|
120
|
+
if (!path || bytes <= 0) return;
|
|
121
|
+
unreclaimableSpillPaths.set(path, bytes);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Drop charges for paths that have since disappeared; returns the surviving total. */
|
|
125
|
+
function reconcileUnreclaimableSpillPaths(): number {
|
|
126
|
+
let total = 0;
|
|
127
|
+
for (const [path, bytes] of [...unreclaimableSpillPaths]) {
|
|
128
|
+
if (existsSync(path)) total += bytes;
|
|
129
|
+
else unreclaimableSpillPaths.delete(path);
|
|
130
|
+
}
|
|
131
|
+
return total;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Peak on-disk footprint of publishing this candidate: temp plus destination copy.
|
|
136
|
+
*
|
|
137
|
+
* Measured from the production serializer rather than from `candidate.sizeBytes`. The
|
|
138
|
+
* resident measurement omits the `version` field the published envelope carries, so
|
|
139
|
+
* pricing an admission by it undercounts and lets a request sitting exactly at the cap
|
|
140
|
+
* still exceed it. Falls back to the resident figure only when serialization fails, which
|
|
141
|
+
* is the same condition that will fail the publication itself.
|
|
142
|
+
*/
|
|
143
|
+
function publicationFootprintBytes(id: string, candidate: ResidentResponseState): number {
|
|
144
|
+
const exact = prospectiveResponseSpillBytes(id, spillPayloadForResident(candidate));
|
|
145
|
+
return (exact ?? candidate.sizeBytes) * 2;
|
|
146
|
+
}
|
|
147
|
+
let responseSpillPublicationTail: Promise<void> = Promise.resolve();
|
|
148
|
+
let responseSpillShutdownBudgetOverride: { totalMs: number; fallbackReserveMs: number } | null = null;
|
|
149
|
+
let responseSpillShutdownTerminalizationPassLimitOverride: number | null = null;
|
|
150
|
+
let responseSpillAsyncAclAttemptBudgetOverride: number | null = null;
|
|
151
|
+
|
|
152
|
+
function releasePendingResponseSpill(job: PendingResponseSpill): void {
|
|
153
|
+
if (job.released) return;
|
|
154
|
+
job.released = true;
|
|
155
|
+
pendingResponseSpillBytes = Math.max(0, pendingResponseSpillBytes - job.sizeBytes);
|
|
156
|
+
reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - job.reservedBytes);
|
|
157
|
+
pendingResponseSpills.delete(job);
|
|
158
|
+
if (pendingResponseSpillById.get(job.id) === job) pendingResponseSpillById.delete(job.id);
|
|
159
|
+
job.candidate = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function cancelPendingResponseSpill(id: string): ResponseSpillRef | undefined {
|
|
163
|
+
const job = pendingResponseSpillById.get(id);
|
|
164
|
+
if (!job) return undefined;
|
|
165
|
+
pendingResponseSpillById.delete(id);
|
|
166
|
+
job.cancelled = true;
|
|
167
|
+
markResponseSpillPublicationSuperseded(job.publicationControl);
|
|
168
|
+
const superseded = job.supersededSpill;
|
|
169
|
+
// Ownership TRANSFERS to the caller. Leaving the ref on the cancelled job would let the
|
|
170
|
+
// accounting walk count the same physical file twice — once here and once on the
|
|
171
|
+
// replacement — and an overcount evicts live continuations to make room for bytes that
|
|
172
|
+
// are not there.
|
|
173
|
+
delete job.supersededSpill;
|
|
174
|
+
// A queued job has not captured the candidate in an async frame yet, so release it now.
|
|
175
|
+
// A running job retains its accounting until settlement and will discard its stale file.
|
|
176
|
+
if (!job.running) releasePendingResponseSpill(job);
|
|
177
|
+
return superseded;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isAclTimeout(error: unknown): boolean {
|
|
181
|
+
return !!error && typeof error === "object" && "code" in error
|
|
182
|
+
&& String((error as { code?: unknown }).code) === "ETIMEDOUT";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function spillPayloadForResident(candidate: ResidentResponseState): Parameters<typeof writeResponseSpillDurably>[1] {
|
|
186
|
+
return {
|
|
187
|
+
createdAt: candidate.createdAt,
|
|
188
|
+
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
189
|
+
items: candidate.items,
|
|
190
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
191
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function runPendingResponseSpill(job: PendingResponseSpill): Promise<void> {
|
|
196
|
+
if (job.cancelled || !job.candidate) return;
|
|
197
|
+
job.running = true;
|
|
198
|
+
const candidate = job.candidate;
|
|
199
|
+
let ref: ResponseSpillRef | null = null;
|
|
200
|
+
let exhaustedAclRetry = false;
|
|
201
|
+
let aclRetryFailureOrigin: ResponseSpillWriteFailureOrigin | null = null;
|
|
202
|
+
try {
|
|
203
|
+
const state = spillPayloadForResident(candidate);
|
|
204
|
+
try {
|
|
205
|
+
ref = await writeResponseSpillDurablyAsync(job.id, state, {
|
|
206
|
+
aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(),
|
|
207
|
+
publicationControl: job.publicationControl,
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!isAclTimeout(error)) throw error;
|
|
211
|
+
// The ACL helper permits exactly one caller-owned recovery budget. The resident generation
|
|
212
|
+
// remains replayable during both attempts, so a transient timeout never becomes a tombstone.
|
|
213
|
+
try {
|
|
214
|
+
ref = await writeResponseSpillDurablyAsync(job.id, state, {
|
|
215
|
+
aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(),
|
|
216
|
+
retryTimedOutOnce: true,
|
|
217
|
+
publicationControl: job.publicationControl,
|
|
218
|
+
});
|
|
219
|
+
} catch (retryError) {
|
|
220
|
+
exhaustedAclRetry = isAclTimeout(retryError);
|
|
221
|
+
// A returned timeout can also mean an exhausted budget before the next OS command.
|
|
222
|
+
aclRetryFailureOrigin = spillAclMemoRefusalOrigin(retryError)
|
|
223
|
+
?? (exhaustedAclRetry ? "retry_returned_timeout" : null);
|
|
224
|
+
throw retryError;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (ref.payloadBytes > responseSpillPayloadCap()) {
|
|
228
|
+
deleteResponseSpill(ref);
|
|
229
|
+
ref = null;
|
|
230
|
+
if (job.directAdmission) admissionCounters.oversizedDrops += 1;
|
|
231
|
+
throw Object.assign(new Error("Response spill payload exceeds replay ceiling"), { code: "EFBIG" });
|
|
232
|
+
}
|
|
233
|
+
if (requireStore().currentEntry(job.id) !== candidate || job.cancelled) {
|
|
234
|
+
deleteResponseSpill(ref);
|
|
235
|
+
ref = null;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (requireStore().swapResidentForSpill(job.id, candidate, ref)) {
|
|
239
|
+
ref = null;
|
|
240
|
+
noteSpillWriteSuccess();
|
|
241
|
+
if (job.directAdmission) admissionCounters.directSpills += 1;
|
|
242
|
+
requireStore().deferSupersededSpill(job.supersededSpill);
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (ref) deleteResponseSpill(ref);
|
|
246
|
+
if (requireStore().currentEntry(job.id) === candidate && !job.cancelled) {
|
|
247
|
+
noteSpillWriteFailure(error, exhaustedAclRetry ? "EACLRETRYEXHAUSTED" : undefined, aclRetryFailureOrigin);
|
|
248
|
+
requireStore().replaceWithSpillFailure(job.id, candidate);
|
|
249
|
+
requireStore().deferSupersededSpill(job.supersededSpill);
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
const cancelled = job.cancelled;
|
|
253
|
+
releasePendingResponseSpill(job);
|
|
254
|
+
requireStore().recomputeOldestResident();
|
|
255
|
+
if (!cancelled) {
|
|
256
|
+
requireStore().schedulePersist();
|
|
257
|
+
requireStore().pruneResponses();
|
|
258
|
+
enforceAppOwnedMemoryBudget();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function queuePendingResponseSpill(
|
|
264
|
+
id: string,
|
|
265
|
+
candidate: ResidentResponseState,
|
|
266
|
+
options: { supersededSpill?: ResponseSpillRef; directAdmission?: boolean } = {},
|
|
267
|
+
): void {
|
|
268
|
+
const inheritedSpill = cancelPendingResponseSpill(id) ?? options.supersededSpill;
|
|
269
|
+
if (pendingResponseSpillBytes + candidate.sizeBytes > MAX_PENDING_RESPONSE_SPILL_BYTES) {
|
|
270
|
+
noteSpillWriteFailure(null, "ECAPACITY");
|
|
271
|
+
requireStore().replaceWithSpillFailure(id, candidate);
|
|
272
|
+
requireStore().deferSupersededSpill(inheritedSpill);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Enforce the disk cap BEFORE the temp or destination file is created. Deleting the
|
|
276
|
+
// overflow afterwards is not equivalent: on Windows the file can outlive the decision
|
|
277
|
+
// by as long as ACL hardening takes, which is the window the measured 6.8 GiB
|
|
278
|
+
// accumulated in. Reclaim first, and only refuse if the peak footprint still does not
|
|
279
|
+
// fit — an eviction pass can free a live continuation's worth of room.
|
|
280
|
+
const footprint = publicationFootprintBytes(id, candidate);
|
|
281
|
+
// The superseded generation this job is about to own is already off `states` and not
|
|
282
|
+
// yet on the job, so it is invisible to the walk. Price it here or admission decides
|
|
283
|
+
// against a total that is short by a whole envelope.
|
|
284
|
+
const inheritedBytes = inheritedSpill?.payloadBytes ?? 0;
|
|
285
|
+
if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) {
|
|
286
|
+
requireStore().enforceSpilledResponseBudget();
|
|
287
|
+
if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) {
|
|
288
|
+
noteSpillWriteFailure(null, "ECAPACITY");
|
|
289
|
+
requireStore().replaceWithSpillFailure(id, candidate);
|
|
290
|
+
requireStore().deferSupersededSpill(inheritedSpill);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const job: PendingResponseSpill = {
|
|
295
|
+
id,
|
|
296
|
+
candidate,
|
|
297
|
+
...(inheritedSpill ? { supersededSpill: inheritedSpill } : {}),
|
|
298
|
+
directAdmission: options.directAdmission === true,
|
|
299
|
+
running: false,
|
|
300
|
+
cancelled: false,
|
|
301
|
+
released: false,
|
|
302
|
+
sizeBytes: candidate.sizeBytes,
|
|
303
|
+
reservedBytes: footprint,
|
|
304
|
+
publicationControl: createResponseSpillPublicationControl(),
|
|
305
|
+
};
|
|
306
|
+
pendingResponseSpills.add(job);
|
|
307
|
+
pendingResponseSpillById.set(id, job);
|
|
308
|
+
pendingResponseSpillBytes += job.sizeBytes;
|
|
309
|
+
reservedResponseSpillBytes += job.reservedBytes;
|
|
310
|
+
requireStore().recomputeOldestResident();
|
|
311
|
+
responseSpillPublicationTail = responseSpillPublicationTail
|
|
312
|
+
.then(() => runPendingResponseSpill(job), () => runPendingResponseSpill(job));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function replaceWithPendingResponseSpill(
|
|
316
|
+
id: string,
|
|
317
|
+
candidate: ResidentResponseState,
|
|
318
|
+
expected: StoredResponseState | undefined,
|
|
319
|
+
options: { directAdmission?: boolean } = {},
|
|
320
|
+
): boolean {
|
|
321
|
+
const inheritedSpill = pendingResponseSpillById.get(id)?.supersededSpill
|
|
322
|
+
?? (expected?.kind === "spill" ? expected.spill : undefined);
|
|
323
|
+
if (!requireStore().replaceMapEntry(id, candidate, expected)) return false;
|
|
324
|
+
queuePendingResponseSpill(id, candidate, {
|
|
325
|
+
...(inheritedSpill ? { supersededSpill: inheritedSpill } : {}),
|
|
326
|
+
directAdmission: options.directAdmission === true,
|
|
327
|
+
});
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Test-only: settle every serialized Windows spill publication. */
|
|
332
|
+
export async function flushPendingResponseSpillsForTests(): Promise<void> {
|
|
333
|
+
await drainResponseSpillPublications();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Test-only: observe ordinary queue settlement without invoking shutdown fallback. */
|
|
337
|
+
export async function awaitResponseSpillPublicationTailForTests(): Promise<void> {
|
|
338
|
+
await responseSpillPublicationTail;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Test-only: observe the bounded queue without exposing payloads. */
|
|
342
|
+
export function pendingResponseSpillMetricsForTests(): { count: number; bytes: number } {
|
|
343
|
+
return { count: pendingResponseSpills.size, bytes: pendingResponseSpillBytes };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Test-only: shorten the shutdown drain/fallback budget (null restores production values). */
|
|
347
|
+
export function setResponseSpillShutdownBudgetForTests(
|
|
348
|
+
budget: { totalMs: number; fallbackReserveMs: number } | null,
|
|
349
|
+
): void {
|
|
350
|
+
responseSpillShutdownBudgetOverride = budget;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Test-only: shorten the ordinary async whole-attempt ACL budget. */
|
|
354
|
+
export function setResponseSpillAsyncAclAttemptBudgetForTests(budgetMs: number | null): void {
|
|
355
|
+
responseSpillAsyncAclAttemptBudgetOverride = budgetMs;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function responseSpillAsyncAclAttemptBudgetMs(): number {
|
|
359
|
+
return responseSpillAsyncAclAttemptBudgetOverride ?? RESPONSE_SPILL_ASYNC_ACL_ATTEMPT_BUDGET_MS;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Test-only: lower the hard terminalization pass guard (null restores production). */
|
|
363
|
+
export function setResponseSpillShutdownTerminalizationPassLimitForTests(limit: number | null): void {
|
|
364
|
+
responseSpillShutdownTerminalizationPassLimitOverride = limit;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function responseSpillShutdownTerminalizationPassLimit(): number {
|
|
368
|
+
return responseSpillShutdownTerminalizationPassLimitOverride
|
|
369
|
+
?? requireStore().terminalizationMaxPasses();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function responseSpillShutdownBudget(): { totalMs: number; fallbackReserveMs: number } {
|
|
373
|
+
return responseSpillShutdownBudgetOverride ?? {
|
|
374
|
+
totalMs: RESPONSE_SPILL_SHUTDOWN_BUDGET_MS,
|
|
375
|
+
fallbackReserveMs: RESPONSE_SPILL_SHUTDOWN_FALLBACK_RESERVE_MS,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function awaitResponseSpillTailUntil(observed: Promise<void>, deadline: number): Promise<boolean> {
|
|
380
|
+
const remaining = deadline - Date.now();
|
|
381
|
+
if (remaining <= 0) return Promise.resolve(false);
|
|
382
|
+
return new Promise(resolve => {
|
|
383
|
+
let finished = false;
|
|
384
|
+
const finish = (settled: boolean): void => {
|
|
385
|
+
if (finished) return;
|
|
386
|
+
finished = true;
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
resolve(settled);
|
|
389
|
+
};
|
|
390
|
+
const timer = setTimeout(() => finish(false), remaining);
|
|
391
|
+
observed.then(() => finish(true), () => finish(true));
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function installShutdownFallbackSpill(
|
|
396
|
+
job: PendingResponseSpill,
|
|
397
|
+
candidate: ResidentResponseState,
|
|
398
|
+
aclBudgetMs: number,
|
|
399
|
+
): void {
|
|
400
|
+
let ref: ResponseSpillRef | null = null;
|
|
401
|
+
// Supersession released this job's reservation, but the synchronous write below is the
|
|
402
|
+
// largest publication of the shutdown path and has its own link-then-copy fallback
|
|
403
|
+
// holding a temp and a destination at once. Re-reserve for its duration so the cap is
|
|
404
|
+
// not blind exactly where the drain does its heaviest work, and settle in `finally` so
|
|
405
|
+
// every return, throw and mismatch releases it.
|
|
406
|
+
const footprint = publicationFootprintBytes(job.id, candidate);
|
|
407
|
+
reservedResponseSpillBytes += footprint;
|
|
408
|
+
try {
|
|
409
|
+
// Supersession released this job, so its superseded generation is no longer visible
|
|
410
|
+
// to the accounting walk — but the file is still on the volume until
|
|
411
|
+
// `deferSupersededSpill` or a delete takes it. Price it here or the fallback decides
|
|
412
|
+
// against a total short by that whole envelope, which is exactly the gap that lets
|
|
413
|
+
// `debt + footprint <= cap < old + debt + footprint` publish over budget.
|
|
414
|
+
const supersededBytes = job.supersededSpill?.payloadBytes ?? 0;
|
|
415
|
+
// The drain must not publish over the cap either. Reclaim first; if the footprint
|
|
416
|
+
// still does not fit — which is what unreclaimable cleanup debt looks like — the
|
|
417
|
+
// honest close-out is a tombstone, not another file on a volume that is already
|
|
418
|
+
// over budget. `replaceWithSpillFailure` is the same fail-closed ending the budget
|
|
419
|
+
// exhaustion path uses, so replay reports `spill_failed` and the client resends.
|
|
420
|
+
if (requireStore().accountedResponseSpillBytes() + supersededBytes > requireStore().spillByteCap()) {
|
|
421
|
+
requireStore().enforceSpilledResponseBudget();
|
|
422
|
+
if (requireStore().accountedResponseSpillBytes() + supersededBytes > requireStore().spillByteCap()) {
|
|
423
|
+
if (requireStore().currentEntry(job.id) === candidate) {
|
|
424
|
+
noteSpillWriteFailure(null, "ECAPACITY");
|
|
425
|
+
requireStore().replaceWithSpillFailure(job.id, candidate);
|
|
426
|
+
requireStore().deferSupersededSpill(job.supersededSpill);
|
|
427
|
+
}
|
|
428
|
+
throw Object.assign(new Error("Response spill shutdown fallback exceeds the durable disk cap"), { code: "ENOSPC" });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
ref = writeResponseSpillDurably(job.id, spillPayloadForResident(candidate), { aclBudgetMs });
|
|
432
|
+
if (ref.payloadBytes > responseSpillPayloadCap()) {
|
|
433
|
+
deleteResponseSpill(ref);
|
|
434
|
+
ref = null;
|
|
435
|
+
if (job.directAdmission) admissionCounters.oversizedDrops += 1;
|
|
436
|
+
throw Object.assign(new Error("Response spill payload exceeds replay ceiling"), { code: "EFBIG" });
|
|
437
|
+
}
|
|
438
|
+
if (requireStore().currentEntry(job.id) !== candidate) {
|
|
439
|
+
deleteResponseSpill(ref);
|
|
440
|
+
ref = null;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (requireStore().swapResidentForSpill(job.id, candidate, ref)) {
|
|
444
|
+
ref = null;
|
|
445
|
+
noteSpillWriteSuccess();
|
|
446
|
+
if (job.directAdmission) admissionCounters.directSpills += 1;
|
|
447
|
+
requireStore().deferSupersededSpill(job.supersededSpill);
|
|
448
|
+
}
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (ref) deleteResponseSpill(ref);
|
|
451
|
+
if (requireStore().currentEntry(job.id) === candidate) {
|
|
452
|
+
noteSpillWriteFailure(error);
|
|
453
|
+
requireStore().replaceWithSpillFailure(job.id, candidate);
|
|
454
|
+
requireStore().deferSupersededSpill(job.supersededSpill);
|
|
455
|
+
}
|
|
456
|
+
throw error;
|
|
457
|
+
} finally {
|
|
458
|
+
reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - footprint);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function terminalizeShutdownFallbackCandidate(
|
|
463
|
+
job: PendingResponseSpill,
|
|
464
|
+
candidate: ResidentResponseState,
|
|
465
|
+
failureCode: ResponseSpillWriteFailureCode = "ETIMEDOUT",
|
|
466
|
+
): void {
|
|
467
|
+
if (requireStore().currentEntry(job.id) !== candidate) return;
|
|
468
|
+
noteSpillWriteFailure(null, failureCode);
|
|
469
|
+
requireStore().replaceWithSpillFailure(job.id, candidate);
|
|
470
|
+
requireStore().deferSupersededSpill(job.supersededSpill);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function pendingShutdownFallbackCandidates(): Array<{
|
|
474
|
+
job: PendingResponseSpill;
|
|
475
|
+
candidate: ResidentResponseState;
|
|
476
|
+
}> {
|
|
477
|
+
return [...pendingResponseSpills]
|
|
478
|
+
.map(job => ({ job, candidate: job.candidate }))
|
|
479
|
+
.filter((entry): entry is { job: PendingResponseSpill; candidate: ResidentResponseState } => !!entry.candidate);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function supersedeShutdownFallbackBatch(
|
|
483
|
+
pending: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>,
|
|
484
|
+
failures: Error[],
|
|
485
|
+
): void {
|
|
486
|
+
for (const { job } of pending) {
|
|
487
|
+
job.cancelled = true;
|
|
488
|
+
markResponseSpillPublicationSuperseded(job.publicationControl);
|
|
489
|
+
}
|
|
490
|
+
for (const { job } of pending) {
|
|
491
|
+
const cleanupFailure = cleanupSupersededResponseSpillPublication(job.publicationControl);
|
|
492
|
+
if (cleanupFailure) {
|
|
493
|
+
failures.push(cleanupFailure);
|
|
494
|
+
// Cleanup failed, so an async temp or destination is STILL on the volume. Releasing
|
|
495
|
+
// the reservation would un-account a file that exists, and the fallback write that
|
|
496
|
+
// follows reserves only its own footprint — three envelopes on disk priced as two.
|
|
497
|
+
//
|
|
498
|
+
// Charge the surviving PATHS rather than a flat two envelopes: `clearOwnedPath`
|
|
499
|
+
// nulls whichever it managed to remove, so one failure is one file, not two. The
|
|
500
|
+
// charge is settled automatically once the path disappears, which a retried unlink
|
|
501
|
+
// or a released Windows lock can still do.
|
|
502
|
+
const perPath = Math.max(1, Math.floor(job.reservedBytes / 2));
|
|
503
|
+
chargeUnreclaimableSpillPath(job.publicationControl.tempPath, perPath);
|
|
504
|
+
chargeUnreclaimableSpillPath(job.publicationControl.destinationPath, perPath);
|
|
505
|
+
}
|
|
506
|
+
releasePendingResponseSpill(job);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function stopAtShutdownTerminalizationPassLimit(
|
|
511
|
+
pending: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>,
|
|
512
|
+
failures: Error[],
|
|
513
|
+
): void {
|
|
514
|
+
failures.push(Object.assign(new Error("Response spill shutdown terminalization pass limit exceeded"), { code: "ELOOP" }));
|
|
515
|
+
supersedeShutdownFallbackBatch(pending, failures);
|
|
516
|
+
for (const { job, candidate } of pending) {
|
|
517
|
+
terminalizeShutdownFallbackCandidate(job, candidate, "ELOOP");
|
|
518
|
+
}
|
|
519
|
+
for (const [id, state] of requireStore().residentEntries()) {
|
|
520
|
+
if (state.kind !== "resident") continue;
|
|
521
|
+
noteSpillWriteFailure(null, "ELOOP");
|
|
522
|
+
requireStore().replaceWithSpillFailure(id, state);
|
|
523
|
+
}
|
|
524
|
+
requireStore().recomputeOldestResident();
|
|
525
|
+
requireStore().pruneResponses();
|
|
526
|
+
enforceAppOwnedMemoryBudget();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function terminalizeExhaustedShutdownFallback(
|
|
530
|
+
initial: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>,
|
|
531
|
+
failures: Error[],
|
|
532
|
+
): void {
|
|
533
|
+
let pending = initial;
|
|
534
|
+
let passes = 0;
|
|
535
|
+
const passLimit = responseSpillShutdownTerminalizationPassLimit();
|
|
536
|
+
// Every pass replaces each captured resident with a tombstone. Pruning may expose
|
|
537
|
+
// another finite batch, but resident count strictly decreases until none can requeue.
|
|
538
|
+
while (pending.length > 0) {
|
|
539
|
+
if (passes >= passLimit) {
|
|
540
|
+
stopAtShutdownTerminalizationPassLimit(pending, failures);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
passes += 1;
|
|
544
|
+
supersedeShutdownFallbackBatch(pending, failures);
|
|
545
|
+
for (const { job, candidate } of pending) {
|
|
546
|
+
failures.push(Object.assign(new Error("Response spill shutdown fallback budget exhausted"), { code: "ETIMEDOUT" }));
|
|
547
|
+
terminalizeShutdownFallbackCandidate(job, candidate);
|
|
548
|
+
}
|
|
549
|
+
requireStore().recomputeOldestResident();
|
|
550
|
+
requireStore().pruneResponses();
|
|
551
|
+
enforceAppOwnedMemoryBudget();
|
|
552
|
+
pending = pendingShutdownFallbackCandidates();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function fallbackPendingResponseSpills(reserveMs: number): Error[] {
|
|
557
|
+
const deadline = Date.now() + reserveMs;
|
|
558
|
+
const failures: Error[] = [];
|
|
559
|
+
for (;;) {
|
|
560
|
+
const pending = pendingShutdownFallbackCandidates();
|
|
561
|
+
if (pending.length === 0) return failures;
|
|
562
|
+
if (Date.now() >= deadline) {
|
|
563
|
+
terminalizeExhaustedShutdownFallback(pending, failures);
|
|
564
|
+
return failures;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
supersedeShutdownFallbackBatch(pending, failures);
|
|
568
|
+
let reserveExhausted = false;
|
|
569
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
570
|
+
const { job, candidate } = pending[index]!;
|
|
571
|
+
if (requireStore().currentEntry(job.id) !== candidate) continue;
|
|
572
|
+
const remaining = deadline - Date.now();
|
|
573
|
+
if (remaining <= 0) {
|
|
574
|
+
reserveExhausted = true;
|
|
575
|
+
for (const exhausted of pending.slice(index)) {
|
|
576
|
+
failures.push(Object.assign(new Error("Response spill shutdown fallback budget exhausted"), { code: "ETIMEDOUT" }));
|
|
577
|
+
terminalizeShutdownFallbackCandidate(exhausted.job, exhausted.candidate);
|
|
578
|
+
}
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
installShutdownFallbackSpill(job, candidate, remaining);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
failures.push(error instanceof Error ? error : new Error("Response spill shutdown fallback failed"));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
requireStore().recomputeOldestResident();
|
|
588
|
+
requireStore().pruneResponses();
|
|
589
|
+
enforceAppOwnedMemoryBudget();
|
|
590
|
+
if (reserveExhausted || Date.now() >= deadline) {
|
|
591
|
+
terminalizeExhaustedShutdownFallback(pendingShutdownFallbackCandidates(), failures);
|
|
592
|
+
return failures;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export async function drainResponseSpillPublications(): Promise<void> {
|
|
598
|
+
const budget = responseSpillShutdownBudget();
|
|
599
|
+
const fallbackReserveMs = Math.min(budget.totalMs, Math.max(1, budget.fallbackReserveMs));
|
|
600
|
+
const drainDeadline = Date.now() + Math.max(0, budget.totalMs - fallbackReserveMs);
|
|
601
|
+
|
|
602
|
+
for (;;) {
|
|
603
|
+
if (pendingResponseSpills.size === 0) return;
|
|
604
|
+
const observed = responseSpillPublicationTail;
|
|
605
|
+
const settled = await awaitResponseSpillTailUntil(observed, drainDeadline);
|
|
606
|
+
if (!settled) {
|
|
607
|
+
const failures = fallbackPendingResponseSpills(fallbackReserveMs);
|
|
608
|
+
if (failures.length > 0) {
|
|
609
|
+
throw new AggregateError(failures, "Response spill shutdown fallback incomplete");
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (observed === responseSpillPublicationTail) return;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Byte accounting the facade's `accountedResponseSpillBytes` adds on top of the
|
|
619
|
+
* installed-spill walk: reserved publication footprint, files a pending job still
|
|
620
|
+
* owns through its superseded generation, and per-path cleanup debt that still
|
|
621
|
+
* exists on the volume.
|
|
622
|
+
*/
|
|
623
|
+
export function spillQueueAccounting(): { reservedBytes: number; jobOwnedBytes: number; unreclaimableBytes: number } {
|
|
624
|
+
let jobOwnedBytes = 0;
|
|
625
|
+
for (const job of pendingResponseSpills) {
|
|
626
|
+
if (job.supersededSpill) jobOwnedBytes += job.supersededSpill.payloadBytes;
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
reservedBytes: reservedResponseSpillBytes,
|
|
630
|
+
jobOwnedBytes,
|
|
631
|
+
unreclaimableBytes: reconcileUnreclaimableSpillPaths(),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Resident candidates still owned by queued publications, for facade-side accounting. */
|
|
636
|
+
export function spillQueueResidentCandidates(): Array<{ id: string; candidate: ResidentResponseState; sizeBytes: number }> {
|
|
637
|
+
const candidates: Array<{ id: string; candidate: ResidentResponseState; sizeBytes: number }> = [];
|
|
638
|
+
for (const job of pendingResponseSpills) {
|
|
639
|
+
if (job.candidate) candidates.push({ id: job.id, candidate: job.candidate, sizeBytes: job.sizeBytes });
|
|
640
|
+
}
|
|
641
|
+
return candidates;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Bytes pinned by queued jobs themselves (not their superseded generations). */
|
|
645
|
+
export function spillQueuePendingBytes(): number {
|
|
646
|
+
return pendingResponseSpillBytes;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** True when this resident entry is the candidate a queued publication will install. */
|
|
650
|
+
export function spillQueueHoldsResidentCandidate(id: string, state: ResidentResponseState): boolean {
|
|
651
|
+
return pendingResponseSpillById.get(id)?.candidate === state;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Superseded generation a queued job will replace, if one is already parked on it. */
|
|
655
|
+
export function spillQueueSupersededSpillFor(id: string): ResponseSpillRef | undefined {
|
|
656
|
+
return pendingResponseSpillById.get(id)?.supersededSpill;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Test-only: release queued jobs and zero the queue-owned byte accounting. */
|
|
660
|
+
export function resetSpillQueueForTests(): void {
|
|
661
|
+
for (const id of [...pendingResponseSpillById.keys()]) cancelPendingResponseSpill(id);
|
|
662
|
+
pendingResponseSpillById.clear();
|
|
663
|
+
reservedResponseSpillBytes = 0;
|
|
664
|
+
unreclaimableSpillPaths.clear();
|
|
665
|
+
}
|