@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.
- package/bin/ocx.mjs +127 -16
- package/gui/dist/assets/index-6HZDkIpX.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -0
- package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
- package/gui/dist/provider-icons/openclaw.svg +54 -0
- package/gui/dist/provider-icons/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/capabilities.ts +14 -0
- package/src/cli/codex-cli-update.ts +96 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +423 -32
- package/src/cli/integrations.ts +24 -2
- package/src/cli/launcher-context.ts +53 -2
- package/src/cli/opencode.ts +126 -33
- package/src/cli/registry.ts +16 -10
- package/src/cli/system-command.ts +6 -1
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +131 -12
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +96 -10
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/cli-install-provenance.ts +795 -0
- package/src/codex/convergence.ts +4 -3
- package/src/codex/credential-mutation-epoch.ts +11 -0
- package/src/codex/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -0
- package/src/codex/main-account.ts +2 -0
- package/src/codex/model-entitlements.ts +430 -27
- package/src/codex/native-profile-manager.ts +4 -0
- package/src/codex/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/routing.ts +68 -16
- package/src/codex/shim.ts +204 -18
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +155 -67
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +107 -16
- package/src/lib/bounded-subprocess.ts +36 -0
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/lib/strict-semver.ts +47 -0
- package/src/lib/windows-elevation.ts +32 -1
- package/src/lib/windows-secret-acl.ts +47 -25
- package/src/lib/windows-service-mutation-lock.ts +133 -0
- package/src/lib/windows-user-principal.ts +15 -17
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +354 -29
- package/src/responses/state.ts +775 -8
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management/model-rows.ts +11 -2
- package/src/server/management/provider-routes.ts +4 -0
- package/src/server/management/system-restart.ts +5 -5
- package/src/server/management-api.ts +72 -13
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/startup-action-control.ts +3 -2
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +749 -53
- package/src/sidecar/candidates.ts +1 -1
- package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +10 -11
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-CowztZdo.js +0 -112
- package/gui/dist/assets/index-jqE_VOKI.css +0 -1
package/src/responses/state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1149
|
-
evictableBytes
|
|
1150
|
-
pinnedBytes: Math.max(0,
|
|
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:
|
|
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 {
|