@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901

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.
Files changed (131) hide show
  1. package/bin/ocx.mjs +127 -16
  2. package/gui/dist/assets/index-6HZDkIpX.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/baseten.svg +13 -0
  7. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  8. package/gui/dist/provider-icons/cerebras.svg +26 -0
  9. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  10. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  11. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  12. package/gui/dist/provider-icons/featherless.svg +4 -0
  13. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  14. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  15. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  16. package/gui/dist/provider-icons/kilo.svg +13 -0
  17. package/gui/dist/provider-icons/litellm.svg +1 -0
  18. package/gui/dist/provider-icons/minimax.svg +1 -0
  19. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  20. package/gui/dist/provider-icons/nebius.svg +1 -0
  21. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  22. package/gui/dist/provider-icons/nous.svg +149 -0
  23. package/gui/dist/provider-icons/novita.svg +32 -0
  24. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  25. package/gui/dist/provider-icons/openclaw.svg +54 -0
  26. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  27. package/gui/dist/provider-icons/parallel.svg +13 -0
  28. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  29. package/gui/dist/provider-icons/sambanova.svg +276 -0
  30. package/gui/dist/provider-icons/scaleway.svg +11 -0
  31. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  32. package/gui/dist/provider-icons/synthetic.svg +12 -0
  33. package/gui/dist/provider-icons/together.svg +18 -0
  34. package/gui/dist/provider-icons/umans.svg +30 -0
  35. package/gui/dist/provider-icons/venice.svg +165 -0
  36. package/gui/dist/provider-icons/vultr.svg +15 -0
  37. package/gui/dist/provider-icons/zai.svg +218 -0
  38. package/gui/dist/provider-icons/zcode.svg +219 -0
  39. package/gui/dist/provider-icons/zenmux.svg +1 -0
  40. package/package.json +1 -1
  41. package/src/adapters/cursor/live-models.ts +1 -0
  42. package/src/adapters/cursor/protobuf-request.ts +4 -1
  43. package/src/adapters/cursor/tool-definitions.ts +36 -4
  44. package/src/adapters/openai-responses.ts +72 -10
  45. package/src/bridge.ts +15 -15
  46. package/src/cli/capabilities.ts +14 -0
  47. package/src/cli/codex-cli-update.ts +96 -0
  48. package/src/cli/codex-shim-autorestore.ts +3 -0
  49. package/src/cli/dispatch.ts +54 -18
  50. package/src/cli/export-command.ts +18 -17
  51. package/src/cli/help.ts +2 -2
  52. package/src/cli/index.ts +423 -32
  53. package/src/cli/integrations.ts +24 -2
  54. package/src/cli/launcher-context.ts +53 -2
  55. package/src/cli/opencode.ts +126 -33
  56. package/src/cli/registry.ts +16 -10
  57. package/src/cli/system-command.ts +6 -1
  58. package/src/cli/uninstall-plan.ts +86 -0
  59. package/src/clients/config-export.ts +293 -28
  60. package/src/codex/account-store.ts +131 -12
  61. package/src/codex/auth-api.ts +202 -33
  62. package/src/codex/autostart-health.ts +3 -3
  63. package/src/codex/catalog/provider-fetch.ts +96 -10
  64. package/src/codex/catalog/sync.ts +4 -3
  65. package/src/codex/cli-install-provenance.ts +795 -0
  66. package/src/codex/convergence.ts +4 -3
  67. package/src/codex/credential-mutation-epoch.ts +11 -0
  68. package/src/codex/history-job.ts +10 -0
  69. package/src/codex/history-manifest.ts +35 -2
  70. package/src/codex/history-provider.ts +196 -19
  71. package/src/codex/history-worker.ts +3 -0
  72. package/src/codex/main-account.ts +2 -0
  73. package/src/codex/model-entitlements.ts +430 -27
  74. package/src/codex/native-profile-manager.ts +4 -0
  75. package/src/codex/quota-401-recovery.ts +190 -0
  76. package/src/codex/quota-recovery-timing.ts +28 -0
  77. package/src/codex/quota.ts +6 -0
  78. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  79. package/src/codex/reset-credit-recovery.ts +20 -2
  80. package/src/codex/routing.ts +68 -16
  81. package/src/codex/shim.ts +204 -18
  82. package/src/codex/subagent-model-fallback.ts +4 -1
  83. package/src/codex/user-identity.ts +2 -1
  84. package/src/config/paths.ts +18 -3
  85. package/src/config/pending-teardown-names.d.mts +8 -0
  86. package/src/config/pending-teardown-names.mjs +69 -0
  87. package/src/config/pending-teardown.ts +286 -0
  88. package/src/config.ts +23 -0
  89. package/src/generated/compatibility-version.json +155 -67
  90. package/src/integrations/journal.ts +12 -1
  91. package/src/integrations/registry.ts +112 -0
  92. package/src/integrations/state.ts +67 -5
  93. package/src/integrations/writer.ts +107 -16
  94. package/src/lib/bounded-subprocess.ts +36 -0
  95. package/src/lib/process-control.ts +30 -5
  96. package/src/lib/state-store-registrations.ts +8 -0
  97. package/src/lib/strict-semver.ts +47 -0
  98. package/src/lib/windows-elevation.ts +32 -1
  99. package/src/lib/windows-secret-acl.ts +47 -25
  100. package/src/lib/windows-service-mutation-lock.ts +133 -0
  101. package/src/lib/windows-user-principal.ts +15 -17
  102. package/src/oauth/index.ts +220 -13
  103. package/src/oauth/store.ts +220 -18
  104. package/src/responses/spill-store.ts +354 -29
  105. package/src/responses/state.ts +775 -8
  106. package/src/server/index.ts +4 -3
  107. package/src/server/lifecycle.ts +5 -1
  108. package/src/server/management/integration-routes.ts +36 -5
  109. package/src/server/management/model-rows.ts +11 -2
  110. package/src/server/management/provider-routes.ts +4 -0
  111. package/src/server/management/system-restart.ts +5 -5
  112. package/src/server/management-api.ts +72 -13
  113. package/src/server/responses/encrypted-payload.ts +18 -1
  114. package/src/server/startup-action-control.ts +3 -2
  115. package/src/server/stop-teardown.ts +84 -0
  116. package/src/service.ts +749 -53
  117. package/src/sidecar/candidates.ts +1 -1
  118. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  119. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  120. package/src/update/index.ts +39 -6
  121. package/src/update/job.ts +10 -11
  122. package/src/update/proxy-liveness-probe.d.mts +6 -0
  123. package/src/update/proxy-liveness-probe.mjs +84 -0
  124. package/src/update/stop-contract.d.mts +2 -0
  125. package/src/update/stop-contract.mjs +15 -0
  126. package/src/update/stop-decision.d.mts +10 -0
  127. package/src/update/stop-decision.mjs +34 -0
  128. package/src/vision/eligibility.ts +19 -1
  129. package/src/vision/index.ts +4 -18
  130. package/gui/dist/assets/index-CowztZdo.js +0 -112
  131. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -3,16 +3,24 @@ import { uptime } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config";
5
5
  import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
6
+ import { windowsSecretAclApplies } from "../lib/windows-secret-acl";
6
7
  import type { OcxProviderContinuationState } from "../types";
7
8
  import {
9
+ cleanupSupersededResponseSpillPublication,
10
+ createResponseSpillPublicationControl,
8
11
  deleteResponseSpill,
12
+ MAX_RESPONSE_SPILL_PAYLOAD_BYTES,
9
13
  noteStubSwapForTest,
10
14
  readResponseSpill,
11
15
  recoverOrphanedResponseSpills,
12
16
  responseSpillDirectory,
13
17
  responseSpillPayloadCap,
18
+ markResponseSpillPublicationSuperseded,
19
+ prospectiveResponseSpillBytes,
20
+ type ResponseSpillPublicationControl,
14
21
  type ResponseSpillRef,
15
22
  writeResponseSpillDurably,
23
+ writeResponseSpillDurablyAsync,
16
24
  } from "./spill-store";
17
25
 
18
26
  const MAX_STORED_RESPONSES = 1_000;
@@ -28,6 +36,30 @@ const SNAPSHOT_DEBOUNCE_MAX_MS = 30_000;
28
36
  * continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
29
37
  * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
30
38
  export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
39
+ /**
40
+ * Aggregate ceiling for the durable spill directory: the disk-side counterpart to
41
+ * the RAM ceiling above. Without it the spilled set is bounded only per-file
42
+ * (MAX_RESPONSE_SPILL_PAYLOAD_BYTES, 256 MiB) and per-entry (MAX_STORED_RESPONSES,
43
+ * 1000), whose product is 250 GiB — larger than the disk of any host this runs on.
44
+ * The only effective bound was therefore RESPONSE_TTL_MS, which makes disk use a
45
+ * function of client request rate rather than of anything this process controls.
46
+ *
47
+ * Measured on one macOS host, 2026-08-30: a client spilling ~150 MB payloads at
48
+ * ~1.4/min held 6.8 GB after 44 minutes, still climbing toward the ~12 GB an
49
+ * hour-long window implies, and filled the volume. Retention itself was correct
50
+ * throughout — the TTL evicted that whole cohort an hour later — so what was
51
+ * missing is a budget, not a sweep.
52
+ *
53
+ * 1 GiB comes from the same sample (n=31), whose spilled sizes are strongly
54
+ * bimodal: median 1.1 MiB against a p90 of 198.7 MiB, near the per-file ceiling.
55
+ * At that median the count cap and this ceiling bind within 8% of each other
56
+ * (1000 x 1.1 MiB = 1.07 GiB), so ordinary traffic sees no eviction it would not
57
+ * already have seen and only the large tail is cut. Erring small is the safe
58
+ * direction: too low costs a replay miss, an already-handled path surfaced as
59
+ * previous_response_not_found, while too high costs the host's disk and every
60
+ * unrelated process on it.
61
+ */
62
+ export const MAX_SPILLED_RESPONSE_BYTES = 1024 * 1024 * 1024;
31
63
  /** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
32
64
  const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
33
65
  const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
@@ -51,6 +83,10 @@ const PERIODIC_TEMP_MAX_CLEANUPS = 64;
51
83
  const PERIODIC_TEMP_SCAN_DEADLINE_MS = 25;
52
84
  const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
53
85
  const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
86
+ const RESPONSE_SPILL_SHUTDOWN_BUDGET_MS = 5_000;
87
+ const RESPONSE_SPILL_SHUTDOWN_FALLBACK_RESERVE_MS = 4_000;
88
+ const RESPONSE_SPILL_ASYNC_ACL_ATTEMPT_BUDGET_MS = 30_000;
89
+ const RESPONSE_SPILL_SHUTDOWN_TERMINALIZATION_MAX_PASSES = MAX_STORED_RESPONSES + 1;
54
90
 
55
91
  interface ResidentResponseState {
56
92
  kind: "resident";
@@ -160,6 +196,562 @@ const pendingSpillUnlinks: ResponseSpillRef[] = [];
160
196
  // structured 400 — bounded-loss, never silent corruption or unbounded disk.
161
197
  const PENDING_SPILL_UNLINKS_MAX = 128;
162
198
 
199
+ /**
200
+ * Windows keeps the candidate replayable while required ACL hardening runs off the event loop.
201
+ * Pending bytes are pinned, not evictable; cap them below the process-owned 512 MiB ceiling so an
202
+ * icacls outage cannot turn the serialized queue into an unbounded resident backlog.
203
+ */
204
+ const MAX_PENDING_RESPONSE_SPILL_BYTES = MAX_RESPONSE_SPILL_PAYLOAD_BYTES;
205
+
206
+ interface PendingResponseSpill {
207
+ id: string;
208
+ candidate: ResidentResponseState | null;
209
+ supersededSpill?: ResponseSpillRef;
210
+ directAdmission: boolean;
211
+ running: boolean;
212
+ cancelled: boolean;
213
+ released: boolean;
214
+ sizeBytes: number;
215
+ /** Peak on-disk bytes reserved for this publication; released exactly once on settle. */
216
+ reservedBytes: number;
217
+ publicationControl: ResponseSpillPublicationControl;
218
+ }
219
+
220
+ const pendingResponseSpills = new Set<PendingResponseSpill>();
221
+ const pendingResponseSpillById = new Map<string, PendingResponseSpill>();
222
+ let pendingResponseSpillBytes = 0;
223
+ /**
224
+ * On-disk bytes a queued publication is about to occupy but has not yet installed into
225
+ * `states`.
226
+ *
227
+ * `spilledResponseBytes()` walks installed spills and deferred unlinks — files that
228
+ * already exist. It cannot see one that `writeResponseSpillDurablyAsync` is in the
229
+ * middle of creating, and on Windows that middle can last as long as `icacls` takes.
230
+ * Without a reservation the cap holds only when writes are fast, which is not a cap.
231
+ *
232
+ * The reserved figure is the PEAK footprint, not the payload: publication can fall back
233
+ * from hard-linking to an exclusive copy, and during that fallback the destination copy
234
+ * and the temp file exist simultaneously. Reserving one envelope would leave the overshoot
235
+ * intact at half its magnitude.
236
+ *
237
+ * Ownership is single: a job holds its reservation from queue until
238
+ * `releasePendingResponseSpill`, which every exit from the publication path reaches
239
+ * through the `finally` in `runPendingResponseSpill` and through cancellation of a
240
+ * not-yet-running job. A leaked reservation is monotonic — it would ratchet the usable
241
+ * cap toward zero — so the release must stay on the settlement path rather than in a
242
+ * parallel bookkeeping pass.
243
+ */
244
+ let reservedResponseSpillBytes = 0;
245
+ /**
246
+ * Paths a failed cleanup left on the volume, with the bytes each one occupies.
247
+ *
248
+ * A failed unlink leaves a real file behind, so the cap has to keep seeing it. But a
249
+ * never-decremented total would be phantom debt: a Windows lock that clears a moment
250
+ * later, or the async writer's own retry, can remove the file while the charge stays
251
+ * forever — and with 256 MiB payloads two conservative charges consume the whole default
252
+ * cap, after which nothing can spill for the life of the process.
253
+ *
254
+ * So the debt is per PATH, priced at what that path actually holds, and settled the
255
+ * moment the path is gone. `reconcileUnreclaimableSpillPaths` re-checks on every read of
256
+ * the accounted total, which is the same tick that would otherwise refuse an admission.
257
+ */
258
+ const unreclaimableSpillPaths = new Map<string, number>();
259
+
260
+ function chargeUnreclaimableSpillPath(path: string | null | undefined, bytes: number): void {
261
+ if (!path || bytes <= 0) return;
262
+ unreclaimableSpillPaths.set(path, bytes);
263
+ }
264
+
265
+ /** Drop charges for paths that have since disappeared; returns the surviving total. */
266
+ function reconcileUnreclaimableSpillPaths(): number {
267
+ let total = 0;
268
+ for (const [path, bytes] of [...unreclaimableSpillPaths]) {
269
+ if (existsSync(path)) total += bytes;
270
+ else unreclaimableSpillPaths.delete(path);
271
+ }
272
+ return total;
273
+ }
274
+
275
+ /**
276
+ * Peak on-disk footprint of publishing this candidate: temp plus destination copy.
277
+ *
278
+ * Measured from the production serializer rather than from `candidate.sizeBytes`. The
279
+ * resident measurement omits the `version` field the published envelope carries, so
280
+ * pricing an admission by it undercounts and lets a request sitting exactly at the cap
281
+ * still exceed it. Falls back to the resident figure only when serialization fails, which
282
+ * is the same condition that will fail the publication itself.
283
+ */
284
+ function publicationFootprintBytes(id: string, candidate: ResidentResponseState): number {
285
+ const exact = prospectiveResponseSpillBytes(id, spillPayloadForResident(candidate));
286
+ return (exact ?? candidate.sizeBytes) * 2;
287
+ }
288
+ let responseSpillPublicationTail: Promise<void> = Promise.resolve();
289
+ let responseSpillShutdownBudgetOverride: { totalMs: number; fallbackReserveMs: number } | null = null;
290
+ let responseSpillShutdownTerminalizationPassLimitOverride: number | null = null;
291
+ let responseSpillAsyncAclAttemptBudgetOverride: number | null = null;
292
+
293
+ function deferSupersededSpill(ref: ResponseSpillRef | undefined): void {
294
+ if (!ref) return;
295
+ pendingSpillUnlinks.push(ref);
296
+ while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
297
+ deleteResponseSpill(pendingSpillUnlinks.shift()!);
298
+ }
299
+ }
300
+
301
+ function releasePendingResponseSpill(job: PendingResponseSpill): void {
302
+ if (job.released) return;
303
+ job.released = true;
304
+ pendingResponseSpillBytes = Math.max(0, pendingResponseSpillBytes - job.sizeBytes);
305
+ reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - job.reservedBytes);
306
+ pendingResponseSpills.delete(job);
307
+ if (pendingResponseSpillById.get(job.id) === job) pendingResponseSpillById.delete(job.id);
308
+ job.candidate = null;
309
+ }
310
+
311
+ function cancelPendingResponseSpill(id: string): ResponseSpillRef | undefined {
312
+ const job = pendingResponseSpillById.get(id);
313
+ if (!job) return undefined;
314
+ pendingResponseSpillById.delete(id);
315
+ job.cancelled = true;
316
+ markResponseSpillPublicationSuperseded(job.publicationControl);
317
+ const superseded = job.supersededSpill;
318
+ // Ownership TRANSFERS to the caller. Leaving the ref on the cancelled job would let the
319
+ // accounting walk count the same physical file twice — once here and once on the
320
+ // replacement — and an overcount evicts live continuations to make room for bytes that
321
+ // are not there.
322
+ delete job.supersededSpill;
323
+ // A queued job has not captured the candidate in an async frame yet, so release it now.
324
+ // A running job retains its accounting until settlement and will discard its stale file.
325
+ if (!job.running) releasePendingResponseSpill(job);
326
+ return superseded;
327
+ }
328
+
329
+ function isAclTimeout(error: unknown): boolean {
330
+ return !!error && typeof error === "object" && "code" in error
331
+ && String((error as { code?: unknown }).code) === "ETIMEDOUT";
332
+ }
333
+
334
+ function spillPayloadForResident(candidate: ResidentResponseState): Parameters<typeof writeResponseSpillDurably>[1] {
335
+ return {
336
+ createdAt: candidate.createdAt,
337
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
338
+ items: candidate.items,
339
+ ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
340
+ ...(candidate.providers ? { providers: candidate.providers } : {}),
341
+ };
342
+ }
343
+
344
+ async function runPendingResponseSpill(job: PendingResponseSpill): Promise<void> {
345
+ if (job.cancelled || !job.candidate) return;
346
+ job.running = true;
347
+ const candidate = job.candidate;
348
+ let ref: ResponseSpillRef | null = null;
349
+ try {
350
+ const state = spillPayloadForResident(candidate);
351
+ try {
352
+ ref = await writeResponseSpillDurablyAsync(job.id, state, {
353
+ aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(),
354
+ publicationControl: job.publicationControl,
355
+ });
356
+ } catch (error) {
357
+ if (!isAclTimeout(error)) throw error;
358
+ // The ACL helper permits exactly one caller-owned recovery budget. The resident generation
359
+ // remains replayable during both attempts, so a transient timeout never becomes a tombstone.
360
+ ref = await writeResponseSpillDurablyAsync(job.id, state, {
361
+ aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(),
362
+ retryTimedOutOnce: true,
363
+ publicationControl: job.publicationControl,
364
+ });
365
+ }
366
+ if (ref.payloadBytes > responseSpillPayloadCap()) {
367
+ deleteResponseSpill(ref);
368
+ ref = null;
369
+ if (job.directAdmission) admissionCounters.oversizedDrops += 1;
370
+ throw Object.assign(new Error("Response spill payload exceeds replay ceiling"), { code: "EFBIG" });
371
+ }
372
+ if (states.get(job.id) !== candidate || job.cancelled) {
373
+ deleteResponseSpill(ref);
374
+ ref = null;
375
+ return;
376
+ }
377
+ if (swapResidentForSpill(job.id, candidate, ref)) {
378
+ ref = null;
379
+ spillCounters.writes += 1;
380
+ if (job.directAdmission) admissionCounters.directSpills += 1;
381
+ deferSupersededSpill(job.supersededSpill);
382
+ }
383
+ } catch {
384
+ if (ref) deleteResponseSpill(ref);
385
+ if (states.get(job.id) === candidate && !job.cancelled) {
386
+ spillCounters.writeFailures += 1;
387
+ replaceWithSpillFailure(job.id, candidate);
388
+ deferSupersededSpill(job.supersededSpill);
389
+ }
390
+ } finally {
391
+ const cancelled = job.cancelled;
392
+ releasePendingResponseSpill(job);
393
+ recomputeOldestResident();
394
+ if (!cancelled) {
395
+ schedulePersist();
396
+ pruneResponses();
397
+ enforceAppOwnedMemoryBudget();
398
+ }
399
+ }
400
+ }
401
+
402
+ function queuePendingResponseSpill(
403
+ id: string,
404
+ candidate: ResidentResponseState,
405
+ options: { supersededSpill?: ResponseSpillRef; directAdmission?: boolean } = {},
406
+ ): void {
407
+ const inheritedSpill = cancelPendingResponseSpill(id) ?? options.supersededSpill;
408
+ if (pendingResponseSpillBytes + candidate.sizeBytes > MAX_PENDING_RESPONSE_SPILL_BYTES) {
409
+ spillCounters.writeFailures += 1;
410
+ replaceWithSpillFailure(id, candidate);
411
+ deferSupersededSpill(inheritedSpill);
412
+ return;
413
+ }
414
+ // Enforce the disk cap BEFORE the temp or destination file is created. Deleting the
415
+ // overflow afterwards is not equivalent: on Windows the file can outlive the decision
416
+ // by as long as ACL hardening takes, which is the window the measured 6.8 GiB
417
+ // accumulated in. Reclaim first, and only refuse if the peak footprint still does not
418
+ // fit — an eviction pass can free a live continuation's worth of room.
419
+ const footprint = publicationFootprintBytes(id, candidate);
420
+ // The superseded generation this job is about to own is already off `states` and not
421
+ // yet on the job, so it is invisible to the walk. Price it here or admission decides
422
+ // against a total that is short by a whole envelope.
423
+ const inheritedBytes = inheritedSpill?.payloadBytes ?? 0;
424
+ if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) {
425
+ enforceSpilledResponseBudget();
426
+ if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) {
427
+ spillCounters.writeFailures += 1;
428
+ replaceWithSpillFailure(id, candidate);
429
+ deferSupersededSpill(inheritedSpill);
430
+ return;
431
+ }
432
+ }
433
+ const job: PendingResponseSpill = {
434
+ id,
435
+ candidate,
436
+ ...(inheritedSpill ? { supersededSpill: inheritedSpill } : {}),
437
+ directAdmission: options.directAdmission === true,
438
+ running: false,
439
+ cancelled: false,
440
+ released: false,
441
+ sizeBytes: candidate.sizeBytes,
442
+ reservedBytes: footprint,
443
+ publicationControl: createResponseSpillPublicationControl(),
444
+ };
445
+ pendingResponseSpills.add(job);
446
+ pendingResponseSpillById.set(id, job);
447
+ pendingResponseSpillBytes += job.sizeBytes;
448
+ reservedResponseSpillBytes += job.reservedBytes;
449
+ recomputeOldestResident();
450
+ responseSpillPublicationTail = responseSpillPublicationTail
451
+ .then(() => runPendingResponseSpill(job), () => runPendingResponseSpill(job));
452
+ }
453
+
454
+ function replaceWithPendingResponseSpill(
455
+ id: string,
456
+ candidate: ResidentResponseState,
457
+ expected: StoredResponseState | undefined,
458
+ options: { directAdmission?: boolean } = {},
459
+ ): boolean {
460
+ const inheritedSpill = pendingResponseSpillById.get(id)?.supersededSpill
461
+ ?? (expected?.kind === "spill" ? expected.spill : undefined);
462
+ if (!replaceMapEntry(id, candidate, expected)) return false;
463
+ queuePendingResponseSpill(id, candidate, {
464
+ ...(inheritedSpill ? { supersededSpill: inheritedSpill } : {}),
465
+ directAdmission: options.directAdmission === true,
466
+ });
467
+ return true;
468
+ }
469
+
470
+ /** Test-only: settle every serialized Windows spill publication. */
471
+ export async function flushPendingResponseSpillsForTests(): Promise<void> {
472
+ await drainResponseSpillPublications();
473
+ }
474
+
475
+ /** Test-only: observe ordinary queue settlement without invoking shutdown fallback. */
476
+ export async function awaitResponseSpillPublicationTailForTests(): Promise<void> {
477
+ await responseSpillPublicationTail;
478
+ }
479
+
480
+ /** Test-only: observe the bounded queue without exposing payloads. */
481
+ export function pendingResponseSpillMetricsForTests(): { count: number; bytes: number } {
482
+ return { count: pendingResponseSpills.size, bytes: pendingResponseSpillBytes };
483
+ }
484
+
485
+ /** Test-only: shorten the shutdown drain/fallback budget (null restores production values). */
486
+ export function setResponseSpillShutdownBudgetForTests(
487
+ budget: { totalMs: number; fallbackReserveMs: number } | null,
488
+ ): void {
489
+ responseSpillShutdownBudgetOverride = budget;
490
+ }
491
+
492
+ /** Test-only: shorten the ordinary async whole-attempt ACL budget. */
493
+ export function setResponseSpillAsyncAclAttemptBudgetForTests(budgetMs: number | null): void {
494
+ responseSpillAsyncAclAttemptBudgetOverride = budgetMs;
495
+ }
496
+
497
+ function responseSpillAsyncAclAttemptBudgetMs(): number {
498
+ return responseSpillAsyncAclAttemptBudgetOverride ?? RESPONSE_SPILL_ASYNC_ACL_ATTEMPT_BUDGET_MS;
499
+ }
500
+
501
+ /** Test-only: lower the hard terminalization pass guard (null restores production). */
502
+ export function setResponseSpillShutdownTerminalizationPassLimitForTests(limit: number | null): void {
503
+ responseSpillShutdownTerminalizationPassLimitOverride = limit;
504
+ }
505
+
506
+ function responseSpillShutdownTerminalizationPassLimit(): number {
507
+ return responseSpillShutdownTerminalizationPassLimitOverride
508
+ ?? RESPONSE_SPILL_SHUTDOWN_TERMINALIZATION_MAX_PASSES;
509
+ }
510
+
511
+ function responseSpillShutdownBudget(): { totalMs: number; fallbackReserveMs: number } {
512
+ return responseSpillShutdownBudgetOverride ?? {
513
+ totalMs: RESPONSE_SPILL_SHUTDOWN_BUDGET_MS,
514
+ fallbackReserveMs: RESPONSE_SPILL_SHUTDOWN_FALLBACK_RESERVE_MS,
515
+ };
516
+ }
517
+
518
+ function awaitResponseSpillTailUntil(observed: Promise<void>, deadline: number): Promise<boolean> {
519
+ const remaining = deadline - Date.now();
520
+ if (remaining <= 0) return Promise.resolve(false);
521
+ return new Promise(resolve => {
522
+ let finished = false;
523
+ const finish = (settled: boolean): void => {
524
+ if (finished) return;
525
+ finished = true;
526
+ clearTimeout(timer);
527
+ resolve(settled);
528
+ };
529
+ const timer = setTimeout(() => finish(false), remaining);
530
+ observed.then(() => finish(true), () => finish(true));
531
+ });
532
+ }
533
+
534
+ function installShutdownFallbackSpill(
535
+ job: PendingResponseSpill,
536
+ candidate: ResidentResponseState,
537
+ aclBudgetMs: number,
538
+ ): void {
539
+ let ref: ResponseSpillRef | null = null;
540
+ // Supersession released this job's reservation, but the synchronous write below is the
541
+ // largest publication of the shutdown path and has its own link-then-copy fallback
542
+ // holding a temp and a destination at once. Re-reserve for its duration so the cap is
543
+ // not blind exactly where the drain does its heaviest work, and settle in `finally` so
544
+ // every return, throw and mismatch releases it.
545
+ const footprint = publicationFootprintBytes(job.id, candidate);
546
+ reservedResponseSpillBytes += footprint;
547
+ try {
548
+ // Supersession released this job, so its superseded generation is no longer visible
549
+ // to the accounting walk — but the file is still on the volume until
550
+ // `deferSupersededSpill` or a delete takes it. Price it here or the fallback decides
551
+ // against a total short by that whole envelope, which is exactly the gap that lets
552
+ // `debt + footprint <= cap < old + debt + footprint` publish over budget.
553
+ const supersededBytes = job.supersededSpill?.payloadBytes ?? 0;
554
+ // The drain must not publish over the cap either. Reclaim first; if the footprint
555
+ // still does not fit — which is what unreclaimable cleanup debt looks like — the
556
+ // honest close-out is a tombstone, not another file on a volume that is already
557
+ // over budget. `replaceWithSpillFailure` is the same fail-closed ending the budget
558
+ // exhaustion path uses, so replay reports `spill_failed` and the client resends.
559
+ if (accountedResponseSpillBytes() + supersededBytes > spillByteCap()) {
560
+ enforceSpilledResponseBudget();
561
+ if (accountedResponseSpillBytes() + supersededBytes > spillByteCap()) {
562
+ if (states.get(job.id) === candidate) {
563
+ spillCounters.writeFailures += 1;
564
+ replaceWithSpillFailure(job.id, candidate);
565
+ deferSupersededSpill(job.supersededSpill);
566
+ }
567
+ throw Object.assign(new Error("Response spill shutdown fallback exceeds the durable disk cap"), { code: "ENOSPC" });
568
+ }
569
+ }
570
+ ref = writeResponseSpillDurably(job.id, spillPayloadForResident(candidate), { aclBudgetMs });
571
+ if (ref.payloadBytes > responseSpillPayloadCap()) {
572
+ deleteResponseSpill(ref);
573
+ ref = null;
574
+ if (job.directAdmission) admissionCounters.oversizedDrops += 1;
575
+ throw Object.assign(new Error("Response spill payload exceeds replay ceiling"), { code: "EFBIG" });
576
+ }
577
+ if (states.get(job.id) !== candidate) {
578
+ deleteResponseSpill(ref);
579
+ ref = null;
580
+ return;
581
+ }
582
+ if (swapResidentForSpill(job.id, candidate, ref)) {
583
+ ref = null;
584
+ spillCounters.writes += 1;
585
+ if (job.directAdmission) admissionCounters.directSpills += 1;
586
+ deferSupersededSpill(job.supersededSpill);
587
+ }
588
+ } catch (error) {
589
+ if (ref) deleteResponseSpill(ref);
590
+ if (states.get(job.id) === candidate) {
591
+ spillCounters.writeFailures += 1;
592
+ replaceWithSpillFailure(job.id, candidate);
593
+ deferSupersededSpill(job.supersededSpill);
594
+ }
595
+ throw error;
596
+ } finally {
597
+ reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - footprint);
598
+ }
599
+ }
600
+
601
+ function terminalizeShutdownFallbackCandidate(
602
+ job: PendingResponseSpill,
603
+ candidate: ResidentResponseState,
604
+ ): void {
605
+ if (states.get(job.id) !== candidate) return;
606
+ spillCounters.writeFailures += 1;
607
+ replaceWithSpillFailure(job.id, candidate);
608
+ deferSupersededSpill(job.supersededSpill);
609
+ }
610
+
611
+ function pendingShutdownFallbackCandidates(): Array<{
612
+ job: PendingResponseSpill;
613
+ candidate: ResidentResponseState;
614
+ }> {
615
+ return [...pendingResponseSpills]
616
+ .map(job => ({ job, candidate: job.candidate }))
617
+ .filter((entry): entry is { job: PendingResponseSpill; candidate: ResidentResponseState } => !!entry.candidate);
618
+ }
619
+
620
+ function supersedeShutdownFallbackBatch(
621
+ pending: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>,
622
+ failures: Error[],
623
+ ): void {
624
+ for (const { job } of pending) {
625
+ job.cancelled = true;
626
+ markResponseSpillPublicationSuperseded(job.publicationControl);
627
+ }
628
+ for (const { job } of pending) {
629
+ const cleanupFailure = cleanupSupersededResponseSpillPublication(job.publicationControl);
630
+ if (cleanupFailure) {
631
+ failures.push(cleanupFailure);
632
+ // Cleanup failed, so an async temp or destination is STILL on the volume. Releasing
633
+ // the reservation would un-account a file that exists, and the fallback write that
634
+ // follows reserves only its own footprint — three envelopes on disk priced as two.
635
+ //
636
+ // Charge the surviving PATHS rather than a flat two envelopes: `clearOwnedPath`
637
+ // nulls whichever it managed to remove, so one failure is one file, not two. The
638
+ // charge is settled automatically once the path disappears, which a retried unlink
639
+ // or a released Windows lock can still do.
640
+ const perPath = Math.max(1, Math.floor(job.reservedBytes / 2));
641
+ chargeUnreclaimableSpillPath(job.publicationControl.tempPath, perPath);
642
+ chargeUnreclaimableSpillPath(job.publicationControl.destinationPath, perPath);
643
+ }
644
+ releasePendingResponseSpill(job);
645
+ }
646
+ }
647
+
648
+ function stopAtShutdownTerminalizationPassLimit(
649
+ pending: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>,
650
+ failures: Error[],
651
+ ): void {
652
+ failures.push(Object.assign(new Error("Response spill shutdown terminalization pass limit exceeded"), { code: "ELOOP" }));
653
+ supersedeShutdownFallbackBatch(pending, failures);
654
+ for (const { job, candidate } of pending) {
655
+ terminalizeShutdownFallbackCandidate(job, candidate);
656
+ }
657
+ for (const [id, state] of [...states]) {
658
+ if (state.kind !== "resident") continue;
659
+ spillCounters.writeFailures += 1;
660
+ replaceWithSpillFailure(id, state);
661
+ }
662
+ recomputeOldestResident();
663
+ pruneResponses();
664
+ enforceAppOwnedMemoryBudget();
665
+ }
666
+
667
+ function terminalizeExhaustedShutdownFallback(
668
+ initial: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>,
669
+ failures: Error[],
670
+ ): void {
671
+ let pending = initial;
672
+ let passes = 0;
673
+ const passLimit = responseSpillShutdownTerminalizationPassLimit();
674
+ // Every pass replaces each captured resident with a tombstone. Pruning may expose
675
+ // another finite batch, but resident count strictly decreases until none can requeue.
676
+ while (pending.length > 0) {
677
+ if (passes >= passLimit) {
678
+ stopAtShutdownTerminalizationPassLimit(pending, failures);
679
+ return;
680
+ }
681
+ passes += 1;
682
+ supersedeShutdownFallbackBatch(pending, failures);
683
+ for (const { job, candidate } of pending) {
684
+ failures.push(Object.assign(new Error("Response spill shutdown fallback budget exhausted"), { code: "ETIMEDOUT" }));
685
+ terminalizeShutdownFallbackCandidate(job, candidate);
686
+ }
687
+ recomputeOldestResident();
688
+ pruneResponses();
689
+ enforceAppOwnedMemoryBudget();
690
+ pending = pendingShutdownFallbackCandidates();
691
+ }
692
+ }
693
+
694
+ function fallbackPendingResponseSpills(reserveMs: number): Error[] {
695
+ const deadline = Date.now() + reserveMs;
696
+ const failures: Error[] = [];
697
+ for (;;) {
698
+ const pending = pendingShutdownFallbackCandidates();
699
+ if (pending.length === 0) return failures;
700
+ if (Date.now() >= deadline) {
701
+ terminalizeExhaustedShutdownFallback(pending, failures);
702
+ return failures;
703
+ }
704
+
705
+ supersedeShutdownFallbackBatch(pending, failures);
706
+ let reserveExhausted = false;
707
+ for (let index = 0; index < pending.length; index += 1) {
708
+ const { job, candidate } = pending[index]!;
709
+ if (states.get(job.id) !== candidate) continue;
710
+ const remaining = deadline - Date.now();
711
+ if (remaining <= 0) {
712
+ reserveExhausted = true;
713
+ for (const exhausted of pending.slice(index)) {
714
+ failures.push(Object.assign(new Error("Response spill shutdown fallback budget exhausted"), { code: "ETIMEDOUT" }));
715
+ terminalizeShutdownFallbackCandidate(exhausted.job, exhausted.candidate);
716
+ }
717
+ break;
718
+ }
719
+ try {
720
+ installShutdownFallbackSpill(job, candidate, remaining);
721
+ } catch (error) {
722
+ failures.push(error instanceof Error ? error : new Error("Response spill shutdown fallback failed"));
723
+ }
724
+ }
725
+ recomputeOldestResident();
726
+ pruneResponses();
727
+ enforceAppOwnedMemoryBudget();
728
+ if (reserveExhausted || Date.now() >= deadline) {
729
+ terminalizeExhaustedShutdownFallback(pendingShutdownFallbackCandidates(), failures);
730
+ return failures;
731
+ }
732
+ }
733
+ }
734
+
735
+ async function drainResponseSpillPublications(): Promise<void> {
736
+ const budget = responseSpillShutdownBudget();
737
+ const fallbackReserveMs = Math.min(budget.totalMs, Math.max(1, budget.fallbackReserveMs));
738
+ const drainDeadline = Date.now() + Math.max(0, budget.totalMs - fallbackReserveMs);
739
+
740
+ for (;;) {
741
+ if (pendingResponseSpills.size === 0) return;
742
+ const observed = responseSpillPublicationTail;
743
+ const settled = await awaitResponseSpillTailUntil(observed, drainDeadline);
744
+ if (!settled) {
745
+ const failures = fallbackPendingResponseSpills(fallbackReserveMs);
746
+ if (failures.length > 0) {
747
+ throw new AggregateError(failures, "Response spill shutdown fallback incomplete");
748
+ }
749
+ return;
750
+ }
751
+ if (observed === responseSpillPublicationTail) return;
752
+ }
753
+ }
754
+
163
755
  function byteCap(): number {
164
756
  return byteCapOverride ?? MAX_STORED_RESPONSE_BYTES;
165
757
  }
@@ -174,6 +766,69 @@ export function getStoredResponseBytesForTests(): number {
174
766
  return storedResponseBytes;
175
767
  }
176
768
 
769
+ let spillByteCapOverride: number | null = null;
770
+
771
+ function spillByteCap(): number {
772
+ return spillByteCapOverride ?? MAX_SPILLED_RESPONSE_BYTES;
773
+ }
774
+
775
+ /**
776
+ * Live total of durable spill payloads. Recomputed per call rather than carried as
777
+ * a running counter: spilled entries reach `states` through several insertion paths
778
+ * (demotion swap, direct oversized admission, snapshot reload), and one missed
779
+ * increment there would silently disable the cap, where an O(MAX_STORED_RESPONSES)
780
+ * walk cannot drift.
781
+ */
782
+ function spilledResponseBytes(): number {
783
+ let total = 0;
784
+ for (const entry of states.values()) {
785
+ if (entry.kind === "spill") total += entry.spill.payloadBytes;
786
+ }
787
+ // Superseded generations awaiting a durable snapshot are still files on disk.
788
+ // Counting only `states` would let PENDING_SPILL_UNLINKS_MAX of them sit outside
789
+ // the budget while it reports itself satisfied.
790
+ for (const ref of pendingSpillUnlinks) total += ref.payloadBytes;
791
+ return total;
792
+ }
793
+
794
+ /**
795
+ * Accounted on-disk bytes: files that exist, plus the peak footprint of publications
796
+ * already in flight.
797
+ *
798
+ * The cap is enforced against this rather than against `spilledResponseBytes()` alone,
799
+ * because a publication that has not finished is still consuming the volume. On Windows
800
+ * the gap between "queued" and "installed" is however long `icacls` takes, and the
801
+ * measured incident this cap answers accumulated 6.8 GiB in 44 minutes.
802
+ */
803
+ function accountedResponseSpillBytes(): number {
804
+ // Superseded generations a pending job still owns are files on disk too. A same-id
805
+ // replacement removes the old spill from `states` and hands its ref to the job, so
806
+ // counting only `states` plus `pendingSpillUnlinks` loses it for the whole publication
807
+ // — during a copy fallback that is old generation + new temp + new destination, three
808
+ // envelopes priced as two.
809
+ let ownedBySpillJobs = 0;
810
+ for (const job of pendingResponseSpills) {
811
+ if (job.supersededSpill) ownedBySpillJobs += job.supersededSpill.payloadBytes;
812
+ }
813
+ return spilledResponseBytes() + reservedResponseSpillBytes + ownedBySpillJobs
814
+ + reconcileUnreclaimableSpillPaths();
815
+ }
816
+
817
+ /** Test-only: lower/restore the durable spill cap (null restores the default). */
818
+ export function setSpilledResponseByteCapForTests(bytes: number | null): void {
819
+ spillByteCapOverride = bytes;
820
+ }
821
+
822
+ /** Test-only: current durable spill accounting (proves evictions unlink their files). */
823
+ export function getSpilledResponseBytesForTests(): number {
824
+ return spilledResponseBytes();
825
+ }
826
+
827
+ /** Test-only: on-disk bytes plus in-flight publication reservations. */
828
+ export function getAccountedResponseSpillBytesForTests(): number {
829
+ return accountedResponseSpillBytes();
830
+ }
831
+
177
832
  function serializedBytes(value: unknown): number | null {
178
833
  try {
179
834
  const serialized = JSON.stringify(value);
@@ -200,6 +855,7 @@ function recomputeOldestResident(): void {
200
855
  oldestResidentAt = null;
201
856
  for (const [id, state] of states) {
202
857
  if (state.kind !== "resident") continue;
858
+ if (pendingResponseSpillById.get(id)?.candidate === state) continue;
203
859
  if (oldestResidentAt !== null && state.createdAt >= oldestResidentAt) continue;
204
860
  oldestResidentId = id;
205
861
  oldestResidentAt = state.createdAt;
@@ -248,6 +904,7 @@ function deleteOwnedSpills(entry: StoredResponseState): void {
248
904
  function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void {
249
905
  const existing = states.get(id);
250
906
  if (!existing) return;
907
+ const supersededSpill = cancelPendingResponseSpill(id);
251
908
  storedResponseBytes -= existing.sizeBytes;
252
909
  if (existing.kind === "resident") {
253
910
  residentResponseBytes -= existing.sizeBytes;
@@ -258,6 +915,7 @@ function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void
258
915
  if (oldestResidentId === id) recomputeOldestResident();
259
916
  stateRevision += 1;
260
917
  if (options.deleteSpill !== false) deleteOwnedSpills(existing);
918
+ if (options.deleteSpill !== false && supersededSpill) deleteResponseSpill(supersededSpill);
261
919
  }
262
920
 
263
921
  function replaceWithSpillFailure(
@@ -362,11 +1020,18 @@ function setResidentEntry(id: string, entry: ResidentInput): void {
362
1020
  pruneResponses();
363
1021
  return;
364
1022
  }
1023
+ const pending = pendingResponseSpillById.get(id);
1024
+ if (windowsSecretAclApplies() && (expected?.kind === "spill" || pending?.supersededSpill)) {
1025
+ replaceWithPendingResponseSpill(id, candidate, expected);
1026
+ pruneResponses();
1027
+ return;
1028
+ }
365
1029
  if (expected?.kind === "spill") {
366
1030
  replaceSpillEntryAtomically(id, expected, candidate);
367
1031
  pruneResponses();
368
1032
  return;
369
1033
  }
1034
+ if (windowsSecretAclApplies()) cancelPendingResponseSpill(id);
370
1035
  if (!replaceMapEntry(id, candidate, expected)) return;
371
1036
  pruneResponses();
372
1037
  }
@@ -389,6 +1054,10 @@ function admitOversizedCandidate(
389
1054
  replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
390
1055
  return;
391
1056
  }
1057
+ if (windowsSecretAclApplies()) {
1058
+ replaceWithPendingResponseSpill(id, candidate, expected, { directAdmission: true });
1059
+ return;
1060
+ }
392
1061
  try {
393
1062
  const ref = writeResponseSpillDurably(id, {
394
1063
  createdAt: candidate.createdAt,
@@ -927,8 +1596,7 @@ function schedulePersist(): void {
927
1596
  schedulePersistAt(snapshotPath());
928
1597
  }
929
1598
 
930
- /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
931
- export async function flushResponseState(): Promise<void> {
1599
+ async function flushResponseSnapshot(): Promise<void> {
932
1600
  if (persistTimer) {
933
1601
  await persistNow(pendingPersistPath ?? snapshotPath(), true);
934
1602
  return;
@@ -941,6 +1609,23 @@ export async function flushResponseState(): Promise<void> {
941
1609
  if (persistTimer) await persistNow(pendingPersistPath ?? snapshotPath(), true);
942
1610
  }
943
1611
 
1612
+ /** Flush publications and snapshot state; report drain failure only after persistence completes. */
1613
+ export async function flushResponseState(): Promise<void> {
1614
+ const failures: unknown[] = [];
1615
+ try {
1616
+ await drainResponseSpillPublications();
1617
+ } catch (error) {
1618
+ failures.push(error);
1619
+ }
1620
+ try {
1621
+ await flushResponseSnapshot();
1622
+ } catch (error) {
1623
+ failures.push(error);
1624
+ }
1625
+ if (failures.length === 1) throw failures[0];
1626
+ if (failures.length > 1) throw new AggregateError(failures, "Response state shutdown flush incomplete");
1627
+ }
1628
+
944
1629
  function inputItems(input: unknown): unknown[] {
945
1630
  if (input === undefined) return [];
946
1631
  if (Array.isArray(input)) return input;
@@ -1036,6 +1721,60 @@ export function replayOverlapSkipsForTests(): number {
1036
1721
  return replayOverlapSkips;
1037
1722
  }
1038
1723
 
1724
+ /**
1725
+ * Bring the durable spill set inside MAX_SPILLED_RESPONSE_BYTES, and report the
1726
+ * bytes released.
1727
+ *
1728
+ * One owner, three callers: mutation pruning, the lazy load that follows a
1729
+ * restart, and the periodic sweep. The periodic caller is not redundant — the
1730
+ * mutation path only runs when traffic arrives, and a process can come up over
1731
+ * budget from a snapshot written under a larger ceiling and then sit idle. That
1732
+ * was observed in production at 1.8 GiB against a 1 GiB cap, held until the first
1733
+ * request.
1734
+ *
1735
+ * NOT covered here: spill files orphaned by a crash. They are absent from
1736
+ * `states`, so this function can neither see nor price them, and they stay with
1737
+ * recoverOrphanedResponseSpills and its RESPONSE_SPILL_ORPHAN_GRACE_MS window.
1738
+ * This ceiling therefore bounds what the store owns, which is every file it can
1739
+ * account for, and not the directory as a whole.
1740
+ */
1741
+ function enforceSpilledResponseBudget(): number {
1742
+ // Price in-flight publications too: a file being created by
1743
+ // `writeResponseSpillDurablyAsync` occupies the volume before it reaches `states`.
1744
+ let spilledBytes = accountedResponseSpillBytes();
1745
+ if (spilledBytes <= spillByteCap()) return 0;
1746
+ const before = spilledBytes;
1747
+ // Deferred generations go first. They are already superseded, so releasing one
1748
+ // costs only the crash window the queue exists to cover — the same trade
1749
+ // PENDING_SPILL_UNLINKS_MAX already makes against unbounded disk. Evicting a
1750
+ // live continuation to make room for a dead file would be the wrong order.
1751
+ while (spilledBytes > spillByteCap() && pendingSpillUnlinks.length > 0) {
1752
+ const ref = pendingSpillUnlinks.shift()!;
1753
+ spilledBytes -= ref.payloadBytes;
1754
+ deleteResponseSpill(ref);
1755
+ }
1756
+ // Ordered by createdAt, not by map order. `states` is not an age index:
1757
+ // demotion and spill replacement delete and reinsert entries, and
1758
+ // writeBoundedSnapshot serializes the map reversed, so map order can put a
1759
+ // newer continuation first — and evicting that one spends a resume the older
1760
+ // entry would not have cost. Sorting is O(k log k) over the spilled subset and
1761
+ // runs only on a tick already over budget.
1762
+ const spilled = [...states]
1763
+ .filter((pair): pair is [string, SpilledResponseState] => pair[1].kind === "spill")
1764
+ // createdAt is millisecond-resolution, so ties are ordinary under load. A
1765
+ // stable sort would then fall back to insertion order — the very order this
1766
+ // is avoiding — so break ties on the response id. Not localeCompare: the
1767
+ // order must not depend on the host locale.
1768
+ .sort((a, b) => a[1].createdAt - b[1].createdAt
1769
+ || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
1770
+ for (const [id, entry] of spilled) {
1771
+ if (spilledBytes <= spillByteCap()) break;
1772
+ spilledBytes -= entry.spill.payloadBytes;
1773
+ deleteEntry(id);
1774
+ }
1775
+ return before - spilledBytes;
1776
+ }
1777
+
1039
1778
  function pruneResponses(at = now()): void {
1040
1779
  for (const [id, state] of states) {
1041
1780
  if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
@@ -1048,7 +1787,11 @@ function pruneResponses(at = now()): void {
1048
1787
  // Unconditional RAM cap. Resident payloads demote durably; stubs/tombstones are
1049
1788
  // deleted only when even their bounded metadata cannot fit the override.
1050
1789
  while (storedResponseBytes > byteCap() && states.size > 0) {
1051
- const oldestResident = [...states].find(([, entry]) => entry.kind === "resident");
1790
+ const oldestResident = [...states].find(([id, entry]) => entry.kind === "resident"
1791
+ && pendingResponseSpillById.get(id)?.candidate !== entry);
1792
+ const hasPendingResident = !oldestResident && [...states].some(([id, entry]) => entry.kind === "resident"
1793
+ && pendingResponseSpillById.get(id)?.candidate === entry);
1794
+ if (hasPendingResident) break;
1052
1795
  const oldestId = oldestResident?.[0] ?? states.keys().next().value as string | undefined;
1053
1796
  if (!oldestId) break;
1054
1797
  const entry = states.get(oldestId)!;
@@ -1056,6 +1799,10 @@ function pruneResponses(at = now()): void {
1056
1799
  deleteEntry(oldestId);
1057
1800
  continue;
1058
1801
  }
1802
+ if (windowsSecretAclApplies()) {
1803
+ queuePendingResponseSpill(oldestId, entry);
1804
+ continue;
1805
+ }
1059
1806
  try {
1060
1807
  const ref = writeResponseSpillDurably(oldestId, {
1061
1808
  createdAt: entry.createdAt,
@@ -1070,6 +1817,7 @@ function pruneResponses(at = now()): void {
1070
1817
  replaceWithSpillFailure(oldestId, entry);
1071
1818
  }
1072
1819
  }
1820
+ enforceSpilledResponseBudget();
1073
1821
  }
1074
1822
 
1075
1823
  /** Periodic TTL-only sweep; count/byte eviction remains owned by mutation paths. */
@@ -1080,7 +1828,10 @@ export function sweepExpiredResponseStates(at = now()): number {
1080
1828
  deleteEntry(id);
1081
1829
  removed += 1;
1082
1830
  }
1083
- if (removed > 0) schedulePersist();
1831
+ // The disk ceiling needs a caller that does not depend on traffic. The return
1832
+ // value stays the TTL count so this function's existing contract is unchanged.
1833
+ const reclaimed = enforceSpilledResponseBudget();
1834
+ if (removed > 0 || reclaimed > 0) schedulePersist();
1084
1835
  return removed;
1085
1836
  }
1086
1837
 
@@ -1143,11 +1894,18 @@ export function sweepAbandonedResponseStateTemps(): number {
1143
1894
  }
1144
1895
 
1145
1896
  export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot {
1897
+ let currentPendingBytes = 0;
1898
+ for (const job of pendingResponseSpills) {
1899
+ if (job.candidate && states.get(job.id) === job.candidate) currentPendingBytes += job.sizeBytes;
1900
+ }
1901
+ const detachedPendingBytes = Math.max(0, pendingResponseSpillBytes - currentPendingBytes);
1902
+ const bytes = storedResponseBytes + detachedPendingBytes;
1903
+ const evictableBytes = Math.max(0, residentResponseBytes - currentPendingBytes);
1146
1904
  return {
1147
1905
  count: states.size,
1148
- bytes: storedResponseBytes,
1149
- evictableBytes: residentResponseBytes,
1150
- pinnedBytes: Math.max(0, storedResponseBytes - residentResponseBytes),
1906
+ bytes,
1907
+ evictableBytes,
1908
+ pinnedBytes: Math.max(0, bytes - evictableBytes),
1151
1909
  oldestAt: oldestResidentAt,
1152
1910
  };
1153
1911
  }
@@ -1157,6 +1915,11 @@ export function evictOldestResponseContinuationForBudget(): number {
1157
1915
  const id = oldestResidentId;
1158
1916
  const entry = states.get(id);
1159
1917
  if (!entry || entry.kind !== "resident") return 0;
1918
+ if (windowsSecretAclApplies()) {
1919
+ queuePendingResponseSpill(id, entry);
1920
+ schedulePersist();
1921
+ return 0;
1922
+ }
1160
1923
  try {
1161
1924
  const ref = writeResponseSpillDurably(id, {
1162
1925
  createdAt: entry.createdAt,
@@ -1380,7 +2143,7 @@ export function responseStateMetrics(): ResponseStateMetrics {
1380
2143
  residentCount,
1381
2144
  spillStubCount,
1382
2145
  tombstoneCount,
1383
- totalBytes: storedResponseBytes,
2146
+ totalBytes: responseContinuationRetainedStoreSnapshot().bytes,
1384
2147
  spillPayloadBytes,
1385
2148
  largestBytes,
1386
2149
  oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0,
@@ -1492,6 +2255,8 @@ export function clearResponseStateMemoryForTests(): void {
1492
2255
  persistTimer = null;
1493
2256
  }
1494
2257
  pendingPersistPath = null;
2258
+ for (const id of [...pendingResponseSpillById.keys()]) cancelPendingResponseSpill(id);
2259
+ pendingResponseSpillById.clear();
1495
2260
  states.clear();
1496
2261
  storedResponseBytes = 0;
1497
2262
  residentResponseBytes = 0;
@@ -1514,6 +2279,8 @@ export function clearResponseStateMemoryForTests(): void {
1514
2279
  export function clearResponseStateForTests(): void {
1515
2280
  for (const entry of states.values()) deleteOwnedSpills(entry);
1516
2281
  clearResponseStateMemoryForTests();
2282
+ reservedResponseSpillBytes = 0;
2283
+ unreclaimableSpillPaths.clear();
1517
2284
  try {
1518
2285
  unlinkSync(snapshotPath());
1519
2286
  } catch {