@bitkyc08/opencodex 2.65.0-preview.20260925 → 2.66.0-preview.20260925
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/README.md +14 -0
- package/gui/dist/assets/App-BVjA0T6O.js +51 -0
- package/gui/dist/assets/App-gkoJIEOV.css +1 -0
- package/gui/dist/assets/{Tray-DUvc_Wul.js → Tray-BCFtiWwD.js} +1 -1
- package/gui/dist/assets/index-ComVsdSk.css +1 -0
- package/gui/dist/assets/index-vO4dgfiU.js +86 -0
- package/gui/dist/assets/{tray-data-f0lTZ4sF.js → tray-data-C4FiNaxk.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +80 -0
- package/src/adapters/anthropic/passthrough.ts +221 -0
- package/src/adapters/anthropic-image-codec.ts +56 -19
- package/src/adapters/anthropic-image-normalize.ts +20 -10
- package/src/adapters/anthropic.ts +55 -24
- package/src/adapters/coding-agent/protocol.ts +29 -19
- package/src/adapters/cursor/live-transport.ts +18 -3
- package/src/adapters/cursor/native-exec-shell.ts +18 -63
- package/src/adapters/cursor/native-exec.ts +5 -2
- package/src/adapters/cursor/native-foreground-shell.ts +44 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -1
- package/src/adapters/exec-tool-result-normalize.ts +4 -1
- package/src/adapters/google.ts +12 -0
- package/src/adapters/ollama-native.ts +32 -1
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
- package/src/adapters/openai-chat.ts +6 -4
- package/src/adapters/openai-responses/passthrough.ts +1 -0
- package/src/adapters/openai-responses/reasoning.ts +45 -1
- package/src/bridge/response-json.ts +10 -2
- package/src/chat/outbound.ts +104 -52
- package/src/claude/claude-code-block.ts +16 -0
- package/src/claude/context-windows.ts +52 -4
- package/src/claude/desktop-first-party.ts +25 -8
- package/src/claude/inbound.ts +22 -0
- package/src/claude/intercept/connect-proxy.ts +37 -4
- package/src/claude/intercept/proxy-auth.ts +166 -0
- package/src/claude/intercept/runtime.ts +21 -0
- package/src/claude/intercept/settings.ts +33 -12
- package/src/claude/outbound.ts +45 -27
- package/src/cli/account-extended.ts +47 -15
- package/src/cli/account-target.ts +115 -0
- package/src/cli/account.ts +20 -28
- package/src/cli/agent.ts +10 -1
- package/src/cli/api-protocols.ts +206 -0
- package/src/cli/capabilities.ts +86 -0
- package/src/cli/combo.ts +2 -2
- package/src/cli/connect.ts +127 -3
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/doctor.ts +33 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/link.ts +288 -0
- package/src/cli/registry.ts +23 -0
- package/src/cli/runtime-api.ts +79 -0
- package/src/cli/system-command.ts +21 -2
- package/src/client/connect.ts +125 -23
- package/src/client/hub-relay.ts +12 -8
- package/src/client/link-join.ts +323 -0
- package/src/client/link-relay.ts +241 -0
- package/src/client/link-state.ts +110 -0
- package/src/client/link-teardown.ts +63 -0
- package/src/client/link-tunnel.ts +465 -0
- package/src/client/machine-listener.ts +14 -5
- package/src/client/runtime.ts +91 -42
- package/src/client/state.ts +4 -0
- package/src/codex/catalog/build-entries.ts +4 -0
- package/src/codex/catalog/derive-entry.ts +5 -0
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +4 -1
- package/src/codex/desktop-switches.ts +88 -8
- package/src/codex/inject/plan.ts +16 -3
- package/src/codex/inject.ts +3 -0
- package/src/codex/internal/catalog-writer.ts +13 -2
- package/src/combos/failover.ts +3 -2
- package/src/combos/index.ts +12 -0
- package/src/combos/jev.ts +646 -0
- package/src/combos/request.ts +5 -0
- package/src/combos/resolve.ts +17 -15
- package/src/combos/types.ts +47 -3
- package/src/config/atomic-write.ts +3 -0
- package/src/config/live-reconcile.ts +20 -4
- package/src/config/persist-unlocked.ts +20 -7
- package/src/config/schema/config-schema.ts +16 -0
- package/src/config/schema/leaf-validators.ts +38 -1
- package/src/generated/compatibility-version.json +416 -128
- package/src/integrations/omp-yaml-source.ts +109 -4
- package/src/lab/automation/orchestrator.ts +60 -4
- package/src/lab/events/limits.ts +1 -1
- package/src/lib/bounded-body.ts +55 -38
- package/src/lib/config-ownership.ts +11 -22
- package/src/lib/local-desktop-snapshot-capability.ts +56 -0
- package/src/lib/local-management-capability.ts +25 -3
- package/src/lib/optional-shutdown-hooks.ts +17 -0
- package/src/lib/state-store-registrations.ts +2 -2
- package/src/lib/windows-elevation.ts +62 -25
- package/src/lib/windows-secret-acl.ts +114 -23
- package/src/link/admission-wait.ts +73 -0
- package/src/link/compensation.ts +100 -0
- package/src/link/fingerprint.ts +21 -0
- package/src/link/paths.ts +19 -0
- package/src/link/ports.ts +12 -0
- package/src/link/routes.ts +32 -0
- package/src/link/ssh-argv.ts +170 -0
- package/src/link/ssh-config.ts +152 -0
- package/src/link/ssh-runner.ts +161 -0
- package/src/link/status-projection.ts +96 -0
- package/src/link/store.ts +139 -0
- package/src/link/supervisor.ts +404 -0
- package/src/link/tunnel-state.ts +91 -0
- package/src/protocols/baseline.ts +88 -0
- package/src/protocols/codecs/chat.ts +17 -0
- package/src/protocols/codecs/messages.ts +18 -0
- package/src/protocols/codecs/responses.ts +17 -0
- package/src/protocols/contract.ts +185 -0
- package/src/protocols/dto.ts +267 -0
- package/src/protocols/encoders/adapter-events.ts +876 -0
- package/src/protocols/encoders/chat.ts +243 -0
- package/src/protocols/encoders/messages.ts +462 -0
- package/src/protocols/envelope.ts +43 -0
- package/src/protocols/features.ts +295 -0
- package/src/protocols/guard.ts +33 -0
- package/src/protocols/opaque-state.ts +132 -0
- package/src/protocols/path.ts +39 -0
- package/src/protocols/plan-snapshot.ts +226 -0
- package/src/protocols/plan.ts +192 -0
- package/src/protocols/provider-summary.ts +101 -0
- package/src/protocols/settings.ts +118 -0
- package/src/protocols/shadow-plan.ts +36 -0
- package/src/protocols/shadow.ts +62 -0
- package/src/protocols/trace.ts +335 -0
- package/src/providers/model-rename-migration.ts +3 -3
- package/src/providers/registry/entries-core.ts +6 -1
- package/src/providers/registry/entries-extended.ts +15 -0
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/types.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/remote-control/workspace-hub.ts +5 -4
- package/src/responses/apply-patch-envelope.ts +6 -1
- package/src/responses/code-mode-shell-input.ts +4 -2
- package/src/responses/freeform-wrapper-scan.ts +51 -24
- package/src/responses/progressive-freeform-input.ts +14 -20
- package/src/responses/spill-store.ts +187 -33
- package/src/responses/state/snapshot-select.ts +49 -0
- package/src/responses/state/spill-inspect.ts +46 -0
- package/src/responses/state/spill-queue.ts +16 -0
- package/src/responses/state.ts +51 -27
- package/src/server/audio-client.ts +8 -3
- package/src/server/audio-upstream.ts +9 -4
- package/src/server/auth-cors.ts +25 -8
- package/src/server/chat-completions.ts +71 -11
- package/src/server/chat-native-eligibility.ts +74 -0
- package/src/server/chat-native.ts +139 -74
- package/src/server/claude-messages.ts +140 -22
- package/src/server/hub-usage.ts +4 -3
- package/src/server/index/link-listener.ts +195 -0
- package/src/server/index/optional-listeners.ts +115 -0
- package/src/server/index/serve-options.ts +44 -10
- package/src/server/index.ts +10 -9
- package/src/server/inference/attempt.ts +51 -0
- package/src/server/inference/client-encoder-delivery.ts +235 -0
- package/src/server/inference/client-wire-log.ts +49 -0
- package/src/server/inference/client-wire.ts +69 -0
- package/src/server/inference/context.ts +15 -0
- package/src/server/inference/final-log.ts +37 -0
- package/src/server/local-desktop-snapshot-auth.ts +57 -0
- package/src/server/management/agent-settings-routes.ts +15 -13
- package/src/server/management/api-access.ts +11 -1
- package/src/server/management/config-routes.ts +3 -6
- package/src/server/management/context.ts +25 -0
- package/src/server/management/link-routes.ts +535 -0
- package/src/server/management/logs-usage-routes.ts +31 -1
- package/src/server/management/native-integration-routes.ts +6 -11
- package/src/server/management/oauth-account-routes.ts +42 -16
- package/src/server/management/protocol-routes.ts +159 -0
- package/src/server/management/protocol-settings-patch.ts +145 -0
- package/src/server/management/provider-patch-transaction.ts +61 -0
- package/src/server/management/provider-routes.ts +39 -18
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sidebar-routes.ts +8 -3
- package/src/server/management/system-routes.ts +18 -1
- package/src/server/management/usage-aggregate-cache.ts +206 -6
- package/src/server/management-api.ts +26 -1
- package/src/server/management-auth.ts +19 -5
- package/src/server/messages-native-eligibility.ts +161 -0
- package/src/server/messages-native-oauth.ts +149 -0
- package/src/server/messages-native.ts +790 -0
- package/src/server/relay.ts +9 -0
- package/src/server/request-log-filter.ts +92 -0
- package/src/server/request-log.ts +31 -70
- package/src/server/responses/adapter-delivery.ts +43 -16
- package/src/server/responses/agent-task-recovery.ts +90 -22
- package/src/server/responses/codex-ws-exchange.ts +4 -0
- package/src/server/responses/codex-ws-wire.ts +10 -1
- package/src/server/responses/core-combo-native.ts +323 -0
- package/src/server/responses/core-combo.ts +224 -13
- package/src/server/responses/core-options.ts +22 -0
- package/src/server/responses/core.ts +14 -26
- package/src/server/responses/request-sidecar-auth.ts +8 -3
- package/src/server/responses/run-turn-execution.ts +257 -63
- package/src/server/responses/sidecar-execution.ts +7 -4
- package/src/server/system-env.ts +7 -0
- package/src/service/diagnostics.ts +7 -1
- package/src/service/launchd.ts +26 -37
- package/src/service/windows-ops.ts +24 -20
- package/src/types/config.ts +58 -1
- package/src/types.ts +2 -0
- package/src/update/transactional-install.d.mts +0 -1
- package/src/update/transactional-install.mjs +17 -17
- package/src/usage/jev-stats.ts +495 -0
- package/src/usage/log.ts +24 -3
- package/src/web-search/run-turn-loop.ts +568 -0
- package/gui/dist/assets/App-8NMiZxT0.css +0 -1
- package/gui/dist/assets/App-CsYvvpr3.js +0 -50
- package/gui/dist/assets/index--EWgGQvZ.css +0 -1
- package/gui/dist/assets/index-BB0iHG8-.js +0 -86
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// `raw` no wrapper can apply, because the text is not an object or because it is one that
|
|
20
20
|
// `JSON.parse` will reject. Completion returns the buffer, so streaming it agrees.
|
|
21
21
|
// `hold` undecided. A key that has not arrived yet can still change the answer, so nothing
|
|
22
|
-
// is published until the object
|
|
22
|
+
// is published until the object closes — where the scan itself already holds the
|
|
23
|
+
// member table completion's own rule consults, so no parse of the buffer is needed.
|
|
23
24
|
//
|
|
24
25
|
// The bound matters as much as the classification. A scan that walks the whole buffer on every
|
|
25
26
|
// delta is quadratic in the argument size, so classification gives up after
|
|
@@ -53,7 +54,7 @@ const HOLD = -1;
|
|
|
53
54
|
const NEVER = -2;
|
|
54
55
|
|
|
55
56
|
export type FreeformWrapperScan =
|
|
56
|
-
| { kind: "hold"
|
|
57
|
+
| { kind: "hold" }
|
|
57
58
|
| { kind: "raw" }
|
|
58
59
|
| { kind: "input"; valueStart: number };
|
|
59
60
|
|
|
@@ -192,38 +193,36 @@ function scanValue(text: string, from: number): number {
|
|
|
192
193
|
/**
|
|
193
194
|
* Which wrapper the completed text will unwrap to, as far as this prefix can say.
|
|
194
195
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
196
|
+
* `fallbackKeys` is the tool's alternate-field vocabulary — `freeformFallbackKeys`, the same
|
|
197
|
+
* list `unwrapFreeformToolInput` filters against. No open prefix can decide a fallback key,
|
|
198
|
+
* because it unwraps only as the SINGLE string field and a second one can still arrive; so the
|
|
199
|
+
* scan keeps a table of the members it has already walked and consults it once, at the close.
|
|
198
200
|
*/
|
|
199
|
-
export function scanFreeformWrapper(text: string): FreeformWrapperScan {
|
|
201
|
+
export function scanFreeformWrapper(text: string, fallbackKeys: readonly string[]): FreeformWrapperScan {
|
|
200
202
|
// One clamp rather than a budget threaded through every helper. Every helper already holds
|
|
201
203
|
// when it runs off the end of what it can see, so a buffer whose classification needs more
|
|
202
204
|
// than this holds for exactly the right reason, and no scan can cost more than this many
|
|
203
205
|
// characters however large the arguments grow. Indices into the clamp are indices into the
|
|
204
206
|
// full text, because the clamp is a prefix of it.
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
// `parse` is true only where this scan actually SAW the object close. Every other hold ran
|
|
209
|
-
// out of buffer or out of budget, and in both cases asking `JSON.parse` is work with no
|
|
210
|
-
// possible payoff: the first is provably incomplete, and the second would re-read a growing
|
|
211
|
-
// buffer on every delta that happens to end in a brace — repeated braces inside a long
|
|
212
|
-
// unterminated string are enough to make that quadratic. Holding a budget-exhausted prefix
|
|
213
|
-
// costs nothing that matters, because the value it would release arrives in the same instant
|
|
214
|
-
// as the authoritative completion that follows it.
|
|
215
|
-
const hold = (): FreeformWrapperScan => ({ kind: "hold", parse: false });
|
|
207
|
+
const wholeText = text.length <= MAX_FREEFORM_WRAPPER_SCAN_CHARS;
|
|
208
|
+
const bounded = wholeText ? text : text.slice(0, MAX_FREEFORM_WRAPPER_SCAN_CHARS);
|
|
209
|
+
const hold = (): FreeformWrapperScan => ({ kind: "hold" });
|
|
216
210
|
const open = skipWhitespace(bounded, 0);
|
|
217
211
|
if (open === HOLD) return hold();
|
|
218
212
|
// Not an object, so no wrapper rule reaches it: arrays, scalars and ordinary bodies all
|
|
219
213
|
// complete as themselves.
|
|
220
214
|
if (bounded[open] !== "{") return { kind: "raw" };
|
|
221
215
|
|
|
216
|
+
// Every top-level member is recorded as the scan passes it, last occurrence winning — the
|
|
217
|
+
// keep-last rule `JSON.parse` applies to duplicate keys. When the object closes, this table
|
|
218
|
+
// answers the only question completion asks of it — which members carried strings — so the
|
|
219
|
+
// closed object classifies directly instead of reparsing a buffer the scan just walked.
|
|
220
|
+
const members = new Map<string, { stringValue: boolean; valueStart: number }>();
|
|
222
221
|
let i = open + 1;
|
|
223
222
|
for (;;) {
|
|
224
223
|
const at = skipWhitespace(bounded, i);
|
|
225
224
|
if (at === HOLD) return hold();
|
|
226
|
-
if (bounded[at] === "}") return afterTopLevelClose(bounded, at + 1);
|
|
225
|
+
if (bounded[at] === "}") return afterTopLevelClose(bounded, at + 1, members, fallbackKeys, wholeText);
|
|
227
226
|
if (bounded[at] !== '"') return { kind: "raw" };
|
|
228
227
|
|
|
229
228
|
const nameEnd = scanString(bounded, at);
|
|
@@ -254,6 +253,7 @@ export function scanFreeformWrapper(text: string): FreeformWrapperScan {
|
|
|
254
253
|
: { kind: "raw" };
|
|
255
254
|
}
|
|
256
255
|
|
|
256
|
+
members.set(name, { stringValue: bounded[valueAt] === '"', valueStart: valueAt + 1 });
|
|
257
257
|
const valueEnd = scanValue(bounded, valueAt);
|
|
258
258
|
if (valueEnd === HOLD) return hold();
|
|
259
259
|
if (valueEnd === NEVER) return { kind: "raw" };
|
|
@@ -261,19 +261,46 @@ export function scanFreeformWrapper(text: string): FreeformWrapperScan {
|
|
|
261
261
|
const next = skipWhitespace(bounded, valueEnd);
|
|
262
262
|
if (next === HOLD) return hold();
|
|
263
263
|
if (bounded[next] === ",") {
|
|
264
|
-
|
|
264
|
+
// A comma promises another member, so the next non-whitespace character must open a
|
|
265
|
+
// member name. `{"code":"cmd",}` is not a completed object — `JSON.parse` rejects the
|
|
266
|
+
// trailing comma — and treating its `}` as the close would unwrap a preview completion
|
|
267
|
+
// hands back unchanged. Nested members already reject this through `scanMemberKey`;
|
|
268
|
+
// the top level has to ask the same question itself.
|
|
269
|
+
const member = skipWhitespace(bounded, next + 1);
|
|
270
|
+
if (member === HOLD) return hold();
|
|
271
|
+
if (bounded[member] !== '"') return { kind: "raw" };
|
|
272
|
+
i = member;
|
|
265
273
|
continue;
|
|
266
274
|
}
|
|
267
|
-
if (bounded[next] === "}") return afterTopLevelClose(bounded, next + 1);
|
|
275
|
+
if (bounded[next] === "}") return afterTopLevelClose(bounded, next + 1, members, fallbackKeys, wholeText);
|
|
268
276
|
return { kind: "raw" };
|
|
269
277
|
}
|
|
270
278
|
}
|
|
271
279
|
|
|
272
280
|
/**
|
|
273
281
|
* The object closed without a canonical key. Only whitespace may follow one that parses, so
|
|
274
|
-
* anything else makes the text raw
|
|
275
|
-
*
|
|
282
|
+
* anything else makes the text raw. Whitespace to the end IS legal, but the member table
|
|
283
|
+
* already says which way completion goes on it: exactly one string-valued fallback field is
|
|
284
|
+
* the wrapper's value, and any other member shape streams the raw text — byte-exact through
|
|
285
|
+
* whatever trailing whitespace arrived, where a reparsed growing buffer was quadratic work on
|
|
286
|
+
* every whitespace delta and a held one swallowed the whitespace bytes entirely.
|
|
276
287
|
*/
|
|
277
|
-
function afterTopLevelClose(
|
|
278
|
-
|
|
288
|
+
function afterTopLevelClose(
|
|
289
|
+
text: string,
|
|
290
|
+
from: number,
|
|
291
|
+
members: ReadonlyMap<string, { stringValue: boolean; valueStart: number }>,
|
|
292
|
+
fallbackKeys: readonly string[],
|
|
293
|
+
wholeText: boolean,
|
|
294
|
+
): FreeformWrapperScan {
|
|
295
|
+
const trailing = skipWhitespace(text, from);
|
|
296
|
+
if (trailing !== HOLD) return { kind: "raw" };
|
|
297
|
+
// A clamped prefix ends inside this whitespace, so what follows is unseen: hold rather than
|
|
298
|
+
// guess at a tail that could still invalidate the close. When the whole text fit, the object
|
|
299
|
+
// plus its trailing whitespace is complete — and its members decide it without a parse.
|
|
300
|
+
if (!wholeText) return { kind: "hold" };
|
|
301
|
+
const candidates = fallbackKeys.filter(key => members.get(key)?.stringValue === true);
|
|
302
|
+
if (candidates.length === 1) {
|
|
303
|
+
return { kind: "input", valueStart: members.get(candidates[0]!)!.valueStart };
|
|
304
|
+
}
|
|
305
|
+
return { kind: "raw" };
|
|
279
306
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { freeformFallbackKeys } from "./apply-patch-envelope";
|
|
2
2
|
import { JSON_ESCAPES, scanFreeformWrapper } from "./freeform-wrapper-scan";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -92,11 +92,12 @@ function decodeJsonStringPrefix(body: string): string | null {
|
|
|
92
92
|
* damage is what is available without giving up progressive streaming, and the args are
|
|
93
93
|
* unusable in that case whichever representation wins.
|
|
94
94
|
*
|
|
95
|
-
* A fallback key
|
|
96
|
-
* second key can still arrive — so a value emitted early would
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
95
|
+
* A fallback key decides only at the object's close. It unwraps as the SINGLE string field,
|
|
96
|
+
* and a second key can still arrive while the object is open — so a value emitted early would
|
|
97
|
+
* have to be taken back. The scan therefore keeps a member table and consults it at the
|
|
98
|
+
* close, publishing the wrapped value (or the raw text) without ever reparsing the buffer.
|
|
99
|
+
* The routed passthrough in `responses-custom-tool-repair.ts` already holds any object prefix
|
|
100
|
+
* for the same reason (#5047).
|
|
100
101
|
*
|
|
101
102
|
* An object that has not reached a canonical key YET is in exactly that position, and used to
|
|
102
103
|
* be treated as raw because it did not match the literal `{"input":"`. It holds now: `input`
|
|
@@ -106,7 +107,7 @@ function decodeJsonStringPrefix(body: string): string | null {
|
|
|
106
107
|
* not holding was publishing bytes the completed item removes.
|
|
107
108
|
*/
|
|
108
109
|
export function progressiveFreeformInput(args: string, toolName: string): string | null {
|
|
109
|
-
const scan = scanFreeformWrapper(args);
|
|
110
|
+
const scan = scanFreeformWrapper(args, freeformFallbackKeys(toolName));
|
|
110
111
|
if (scan.kind === "input") {
|
|
111
112
|
const decoded = decodeJsonStringPrefix(args.slice(scan.valueStart));
|
|
112
113
|
if (decoded === null) return null;
|
|
@@ -114,17 +115,10 @@ export function progressiveFreeformInput(args: string, toolName: string): string
|
|
|
114
115
|
}
|
|
115
116
|
if (scan.kind === "raw") return mayBecomeFencedBody(args, toolName) ? null : args;
|
|
116
117
|
|
|
117
|
-
// Undecided:
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
if (!scan.parse) return null;
|
|
124
|
-
try {
|
|
125
|
-
JSON.parse(args);
|
|
126
|
-
} catch {
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
return unwrapFreeformToolInput(args, toolName);
|
|
118
|
+
// Undecided: an incomplete object, or a close whose tail is clamped out of view. Both stay
|
|
119
|
+
// held — the first is provably incomplete, and re-walking a budget-exhausted buffer on every
|
|
120
|
+
// delta is work for a preview that arrives in the same instant as the authoritative
|
|
121
|
+
// completion behind it. A closed object the scan could see whole never reaches here: its
|
|
122
|
+
// member table already resolved it to `input` or `raw`.
|
|
123
|
+
return null;
|
|
130
124
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
readFileSync,
|
|
14
14
|
unlinkSync,
|
|
15
15
|
writeSync,
|
|
16
|
+
type Stats,
|
|
16
17
|
} from "node:fs";
|
|
17
18
|
import { createHash, randomBytes } from "node:crypto";
|
|
18
19
|
import { dirname, join } from "node:path";
|
|
@@ -34,6 +35,20 @@ export const RESPONSE_SPILL_DIR_NAME = "responses-state-spill";
|
|
|
34
35
|
export const RESPONSE_SPILL_ORPHAN_GRACE_MS = 15 * 60_000;
|
|
35
36
|
export const RESPONSE_SPILL_SCAN_MAX = 4_096;
|
|
36
37
|
export const RESPONSE_SPILL_CLEANUP_MAX = 512;
|
|
38
|
+
// The liveness-tick sweep runs synchronously on the serving event loop every
|
|
39
|
+
// 60 s, so it gets a tighter bound than the startup pass that blocks one call.
|
|
40
|
+
export const PERIODIC_SPILL_SWEEP_OPTS = {
|
|
41
|
+
scanMax: 512,
|
|
42
|
+
cleanupMax: 64,
|
|
43
|
+
deadlineMs: 25,
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/** Open directory iterator the next liveness-tick sweep resumes from; null
|
|
47
|
+
* after a full pass, an iteration error, or a directory change. Holding the
|
|
48
|
+
* real iterator (instead of an offset re-skipped from a fresh opendir) keeps
|
|
49
|
+
* each tick's cost bounded by its own budget, so a slow enumeration cannot
|
|
50
|
+
* pin the cursor at the same prefix forever. */
|
|
51
|
+
let periodicSweepCursor: { dir: string; scan: SpillDirScan } | null = null;
|
|
37
52
|
|
|
38
53
|
const RESPONSE_SPILL_PUBLISH_RETRIES = 64;
|
|
39
54
|
const OWNED_SPILL_NAME = /^([A-Za-z0-9._-]{1,80})\.([0-9a-f]{12})\.([0-9a-f]{24})\.(\d+)\.(\d+)\.spill\.json$/;
|
|
@@ -95,6 +110,8 @@ export interface ResponseSpillCleanupResult {
|
|
|
95
110
|
removed: number;
|
|
96
111
|
failed: number;
|
|
97
112
|
bytesRemoved: number;
|
|
113
|
+
/** The scan reached the end of the directory. */
|
|
114
|
+
exhausted?: boolean;
|
|
98
115
|
}
|
|
99
116
|
|
|
100
117
|
export interface ResponseSpillIoForTest {
|
|
@@ -750,53 +767,153 @@ export function deleteResponseSpill(ref: ResponseSpillRef): void {
|
|
|
750
767
|
} catch { /* best effort */ }
|
|
751
768
|
}
|
|
752
769
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
// SCAN_MAX+1 is never requested from either source.
|
|
770
|
+
type SpillDirNameKind = "spill" | "temp";
|
|
771
|
+
|
|
772
|
+
/** Directory-iteration plumbing shared by reclaim and the dry-run inspector.
|
|
773
|
+
* ONE loop serves both the real directory handle and the injected test seam
|
|
774
|
+
* (review C2-2: two duplicated loops let the test prove only its own copy).
|
|
775
|
+
* Callers must request the next name strictly AFTER their scan-cap check, so
|
|
776
|
+
* entry SCAN_MAX+1 is never requested from either source. */
|
|
777
|
+
interface SpillDirScan { nextName: () => string | null; close: () => void }
|
|
778
|
+
|
|
779
|
+
function openSpillDirScan(dir: string): SpillDirScan | null {
|
|
764
780
|
let handle: ReturnType<typeof opendirSync> | null = null;
|
|
765
781
|
const injected = spillIoForTest?.readdirEntry;
|
|
766
782
|
if (!injected) {
|
|
767
|
-
try { handle = opendirSync(dir); } catch { return
|
|
783
|
+
try { handle = opendirSync(dir); } catch { return null; }
|
|
768
784
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
785
|
+
return {
|
|
786
|
+
nextName: () => {
|
|
787
|
+
if (injected) return injected();
|
|
788
|
+
const entry = handle!.readSync();
|
|
789
|
+
return entry ? entry.name : null;
|
|
790
|
+
},
|
|
791
|
+
close: () => { try { handle?.closeSync(); } catch { /* best effort */ } },
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Orphan predicate, name half: a spill-shaped name nobody references. Shared by
|
|
796
|
+
* reclaim and the dry-run inspector so the report and the unlink cannot disagree. */
|
|
797
|
+
function orphanSpillNameKind(name: string, referencedFileNames: ReadonlySet<string>): SpillDirNameKind | null {
|
|
798
|
+
if (referencedFileNames.has(name)) return null;
|
|
799
|
+
if (OWNED_SPILL_NAME.test(name)) return "spill";
|
|
800
|
+
if (OWNED_SPILL_TEMP_NAME.test(name)) return "temp";
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Orphan predicate, stat half: a regular file (never a symlink) past the grace window. */
|
|
805
|
+
function spillEntryPastGrace(stat: Stats, graceMs: number): boolean {
|
|
806
|
+
return stat.isFile() && !stat.isSymbolicLink() && Date.now() - stat.mtimeMs >= graceMs;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export interface ResponseSpillDirInspection {
|
|
810
|
+
scanned: number;
|
|
811
|
+
/** The walk stopped at RESPONSE_SPILL_SCAN_MAX, so every count describes a
|
|
812
|
+
* prefix of the directory and the real totals are higher. */
|
|
813
|
+
truncated: boolean;
|
|
814
|
+
/** Every regular file present, spill-shaped or not — the honest disk total. */
|
|
815
|
+
files: number;
|
|
816
|
+
bytes: number;
|
|
817
|
+
/** Regular files named in the caller's referenced set. */
|
|
818
|
+
ownedFiles: number;
|
|
819
|
+
ownedBytes: number;
|
|
820
|
+
/** Spill/temp-shaped regular files unreferenced and past the orphan grace. */
|
|
821
|
+
orphanFiles: number;
|
|
822
|
+
orphanBytes: number;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Dry-run counterpart of recoverOrphanedResponseSpills: same directory walk and
|
|
827
|
+
* the same orphan predicate, but it counts and never unlinks. It stats every
|
|
828
|
+
* entry (bounded by RESPONSE_SPILL_SCAN_MAX) because `bytes` is the disk total
|
|
829
|
+
* an operator sizes the directory by — reclaim only stats orphan candidates.
|
|
830
|
+
*/
|
|
831
|
+
export function inspectResponseSpillDir(
|
|
832
|
+
referencedFileNames: ReadonlySet<string>,
|
|
833
|
+
dir = responseSpillDirectory(),
|
|
834
|
+
opts?: { graceMs?: number },
|
|
835
|
+
): ResponseSpillDirInspection {
|
|
836
|
+
const result: ResponseSpillDirInspection = {
|
|
837
|
+
scanned: 0, truncated: false, files: 0, bytes: 0,
|
|
838
|
+
ownedFiles: 0, ownedBytes: 0, orphanFiles: 0, orphanBytes: 0,
|
|
773
839
|
};
|
|
840
|
+
const graceMs = opts?.graceMs ?? RESPONSE_SPILL_ORPHAN_GRACE_MS;
|
|
841
|
+
const scan = openSpillDirScan(dir);
|
|
842
|
+
if (!scan) return result;
|
|
774
843
|
try {
|
|
775
844
|
while (result.scanned < RESPONSE_SPILL_SCAN_MAX) {
|
|
776
|
-
const name = nextName();
|
|
845
|
+
const name = scan.nextName();
|
|
777
846
|
if (name === null) break;
|
|
778
847
|
result.scanned += 1;
|
|
779
|
-
if (result.removed + result.failed >= RESPONSE_SPILL_CLEANUP_MAX) break;
|
|
780
|
-
const spillMatch = OWNED_SPILL_NAME.exec(name);
|
|
781
|
-
const isOwnedTemp = OWNED_SPILL_TEMP_NAME.test(name);
|
|
782
|
-
if ((!spillMatch && !isOwnedTemp) || referencedFileNames.has(name)) continue;
|
|
783
848
|
const path = join(dir, name);
|
|
784
|
-
let stat:
|
|
849
|
+
let stat: Stats;
|
|
785
850
|
try { stat = lstatSync(path); } catch { continue; }
|
|
786
|
-
if (!stat.isFile() || stat.isSymbolicLink()
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
result.
|
|
794
|
-
|
|
795
|
-
result.failed += 1;
|
|
851
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
852
|
+
result.files += 1;
|
|
853
|
+
result.bytes += stat.size;
|
|
854
|
+
if (referencedFileNames.has(name)) {
|
|
855
|
+
result.ownedFiles += 1;
|
|
856
|
+
result.ownedBytes += stat.size;
|
|
857
|
+
} else if (orphanSpillNameKind(name, referencedFileNames) !== null && spillEntryPastGrace(stat, graceMs)) {
|
|
858
|
+
result.orphanFiles += 1;
|
|
859
|
+
result.orphanBytes += stat.size;
|
|
796
860
|
}
|
|
797
861
|
}
|
|
798
862
|
} finally {
|
|
799
|
-
|
|
863
|
+
scan.close();
|
|
864
|
+
}
|
|
865
|
+
result.truncated = result.scanned >= RESPONSE_SPILL_SCAN_MAX;
|
|
866
|
+
return result;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export function recoverOrphanedResponseSpills(
|
|
870
|
+
referencedFileNames: ReadonlySet<string>,
|
|
871
|
+
dir = responseSpillDirectory(),
|
|
872
|
+
opts?: { graceMs?: number; scanMax?: number; cleanupMax?: number; deadlineMs?: number },
|
|
873
|
+
): ResponseSpillCleanupResult {
|
|
874
|
+
const scan = openSpillDirScan(dir);
|
|
875
|
+
if (!scan) return { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
|
|
876
|
+
try {
|
|
877
|
+
return reclaimFromSpillDirScan(scan, referencedFileNames, dir, opts);
|
|
878
|
+
} finally {
|
|
879
|
+
scan.close();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Reclaim loop over an already-open scan. It never reads a name it will not
|
|
884
|
+
* process, so a caller that keeps the scan open resumes at the first entry the
|
|
885
|
+
* previous call left untouched. Throws only if the iterator itself throws. */
|
|
886
|
+
function reclaimFromSpillDirScan(
|
|
887
|
+
scan: SpillDirScan,
|
|
888
|
+
referencedFileNames: ReadonlySet<string>,
|
|
889
|
+
dir: string,
|
|
890
|
+
opts?: { graceMs?: number; scanMax?: number; cleanupMax?: number; deadlineMs?: number },
|
|
891
|
+
): ResponseSpillCleanupResult {
|
|
892
|
+
const result: ResponseSpillCleanupResult = { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
|
|
893
|
+
const graceMs = opts?.graceMs ?? RESPONSE_SPILL_ORPHAN_GRACE_MS;
|
|
894
|
+
const scanMax = opts?.scanMax ?? RESPONSE_SPILL_SCAN_MAX;
|
|
895
|
+
const cleanupMax = opts?.cleanupMax ?? RESPONSE_SPILL_CLEANUP_MAX;
|
|
896
|
+
const deadline = opts?.deadlineMs === undefined ? Infinity : Date.now() + opts.deadlineMs;
|
|
897
|
+
while (result.scanned < scanMax && result.removed + result.failed < cleanupMax && Date.now() < deadline) {
|
|
898
|
+
const name = scan.nextName();
|
|
899
|
+
if (name === null) { result.exhausted = true; break; }
|
|
900
|
+
result.scanned += 1;
|
|
901
|
+
const orphanKind = orphanSpillNameKind(name, referencedFileNames);
|
|
902
|
+
if (orphanKind === null) continue;
|
|
903
|
+
const path = join(dir, name);
|
|
904
|
+
let stat: Stats;
|
|
905
|
+
try { stat = lstatSync(path); } catch { continue; }
|
|
906
|
+
if (!spillEntryPastGrace(stat, graceMs)) continue;
|
|
907
|
+
try {
|
|
908
|
+
// Orphaned publish temps get the full ephemeral release; stable
|
|
909
|
+
// orphaned spills keep destination-keyed timeout memos.
|
|
910
|
+
if (orphanKind === "temp") unlinkEphemeral(path);
|
|
911
|
+
else unlink(path);
|
|
912
|
+
result.removed += 1;
|
|
913
|
+
result.bytesRemoved += stat.size;
|
|
914
|
+
} catch {
|
|
915
|
+
result.failed += 1;
|
|
916
|
+
}
|
|
800
917
|
}
|
|
801
918
|
return result;
|
|
802
919
|
}
|
|
@@ -804,3 +921,40 @@ export function recoverOrphanedResponseSpills(
|
|
|
804
921
|
export function responseSpillExistsForTests(ref: ResponseSpillRef): boolean {
|
|
805
922
|
return validSpillRef(ref) && existsSync(join(responseSpillDirectory(), ref.fileName));
|
|
806
923
|
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Bounded liveness-tick reclaim that resumes where the previous tick stopped,
|
|
927
|
+
* so orphans behind the first `scanMax` owned entries are still reached. The
|
|
928
|
+
* directory iterator stays open between ticks; it is dropped at end of
|
|
929
|
+
* directory, on an iteration error, or when the spill directory changes.
|
|
930
|
+
*/
|
|
931
|
+
export function sweepOrphanedResponseSpillsPeriodically(
|
|
932
|
+
referencedFileNames: ReadonlySet<string>,
|
|
933
|
+
dir = responseSpillDirectory(),
|
|
934
|
+
): ResponseSpillCleanupResult {
|
|
935
|
+
if (periodicSweepCursor && periodicSweepCursor.dir !== dir) closePeriodicSweepCursor();
|
|
936
|
+
if (!periodicSweepCursor) {
|
|
937
|
+
const scan = openSpillDirScan(dir);
|
|
938
|
+
if (!scan) return { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
|
|
939
|
+
periodicSweepCursor = { dir, scan };
|
|
940
|
+
}
|
|
941
|
+
let result: ResponseSpillCleanupResult;
|
|
942
|
+
try {
|
|
943
|
+
result = reclaimFromSpillDirScan(periodicSweepCursor.scan, referencedFileNames, dir, PERIODIC_SPILL_SWEEP_OPTS);
|
|
944
|
+
} catch {
|
|
945
|
+
closePeriodicSweepCursor();
|
|
946
|
+
return { scanned: 0, removed: 0, failed: 0, bytesRemoved: 0 };
|
|
947
|
+
}
|
|
948
|
+
// A full pass restarts from a fresh listing so files created mid-pass are seen.
|
|
949
|
+
if (result.exhausted) closePeriodicSweepCursor();
|
|
950
|
+
return result;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function closePeriodicSweepCursor(): void {
|
|
954
|
+
periodicSweepCursor?.scan.close();
|
|
955
|
+
periodicSweepCursor = null;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export function resetPeriodicSpillSweepCursorForTests(): void {
|
|
959
|
+
closePeriodicSweepCursor();
|
|
960
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { StoredResponseState } from "../state";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pick the snapshot entries that fit the byte budgets, in `states` order.
|
|
5
|
+
*
|
|
6
|
+
* Bounded stubs and tombstones are selected before residents: they are the
|
|
7
|
+
* only durable references a spill file has, and demotion is oldest-first, so a
|
|
8
|
+
* single newest-first pass would let resident payloads consume the whole
|
|
9
|
+
* budget ahead of them. Residents then fill what remains, newest-first so the
|
|
10
|
+
* most recent chains survive both legacy snapshot caps.
|
|
11
|
+
*/
|
|
12
|
+
export function selectSnapshotEntries(
|
|
13
|
+
states: ReadonlyMap<string, StoredResponseState>,
|
|
14
|
+
totalMaxBytes: number,
|
|
15
|
+
residentEntryMaxBytes: number,
|
|
16
|
+
): Array<[string, unknown]> {
|
|
17
|
+
const ordered = [...states].reverse();
|
|
18
|
+
const persisted = new Map<string, [string, unknown]>();
|
|
19
|
+
let total = 0;
|
|
20
|
+
// UTF-8 bytes, not UTF-16 code units: multibyte items otherwise slip past
|
|
21
|
+
// both snapshot caps at up to 2x the intended size.
|
|
22
|
+
const sizeOf = (entry: [string, unknown]): number => Buffer.byteLength(JSON.stringify(entry), "utf8");
|
|
23
|
+
for (const [id, state] of ordered) {
|
|
24
|
+
if (state.kind === "resident") continue;
|
|
25
|
+
const { sizeBytes: _sizeBytes, ...smallState } = state;
|
|
26
|
+
const entry: [string, unknown] = [id, smallState];
|
|
27
|
+
const size = sizeOf(entry);
|
|
28
|
+
if (total + size > totalMaxBytes) continue;
|
|
29
|
+
total += size;
|
|
30
|
+
persisted.set(id, entry);
|
|
31
|
+
}
|
|
32
|
+
for (const [id, state] of ordered) {
|
|
33
|
+
if (state.kind !== "resident") continue;
|
|
34
|
+
const { sizeBytes: _sizeBytes, kind: _kind, ...resident } = state;
|
|
35
|
+
const entry: [string, unknown] = [id, resident];
|
|
36
|
+
const size = sizeOf(entry);
|
|
37
|
+
if (size > residentEntryMaxBytes) continue;
|
|
38
|
+
if (total + size > totalMaxBytes) break;
|
|
39
|
+
total += size;
|
|
40
|
+
persisted.set(id, entry);
|
|
41
|
+
}
|
|
42
|
+
// Emit in map order so reload and count eviction keep the same relative order as `states`.
|
|
43
|
+
const entries: Array<[string, unknown]> = [];
|
|
44
|
+
for (const [id] of states) {
|
|
45
|
+
const kept = persisted.get(id);
|
|
46
|
+
if (kept) entries.push(kept);
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import type { ResponseSpillRef } from "../spill-store";
|
|
3
|
+
import type { StoredResponseState } from "../state";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File names still owned by the in-memory store: live "spill" stubs plus
|
|
7
|
+
* superseded generations queued for unlink only after a durable snapshot
|
|
8
|
+
* (pendingSpillUnlinks — see replaceSpillEntryAtomically in state.ts).
|
|
9
|
+
*/
|
|
10
|
+
export function collectReferencedSpillFileNames(
|
|
11
|
+
states: Iterable<StoredResponseState>,
|
|
12
|
+
pendingUnlinks: Iterable<ResponseSpillRef>,
|
|
13
|
+
): Set<string> {
|
|
14
|
+
const referenced = new Set<string>();
|
|
15
|
+
for (const state of states) {
|
|
16
|
+
if (state.kind === "spill") referenced.add(state.spill.fileName);
|
|
17
|
+
}
|
|
18
|
+
for (const ref of pendingUnlinks) referenced.add(ref.fileName);
|
|
19
|
+
return referenced;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* File names a restart would re-own: "spill" stubs inside the persisted
|
|
24
|
+
* snapshot. This is the only ownership evidence a separate process (ocx
|
|
25
|
+
* doctor) can see, and it stays authoritative in-process too — a restart
|
|
26
|
+
* reloads the snapshot before the store drops anything from it.
|
|
27
|
+
*/
|
|
28
|
+
export function snapshotReferencedSpillFileNames(path: string, maxBytes: number): Set<string> {
|
|
29
|
+
const referenced = new Set<string>();
|
|
30
|
+
try {
|
|
31
|
+
const stat = statSync(path);
|
|
32
|
+
if (!stat.isFile() || stat.size > maxBytes) return referenced;
|
|
33
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
|
|
34
|
+
if ((raw.version !== 1 && raw.version !== 2) || !Array.isArray(raw.states)) return referenced;
|
|
35
|
+
for (const entry of raw.states) {
|
|
36
|
+
if (!Array.isArray(entry) || entry.length !== 2) continue;
|
|
37
|
+
const value = entry[1] as { kind?: unknown; spill?: { fileName?: unknown } };
|
|
38
|
+
if (value?.kind === "spill" && typeof value.spill?.fileName === "string") {
|
|
39
|
+
referenced.add(value.spill.fileName);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
/* missing or corrupt snapshot: nothing is referenced */
|
|
44
|
+
}
|
|
45
|
+
return referenced;
|
|
46
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
2
3
|
import {
|
|
3
4
|
cleanupSupersededResponseSpillPublication,
|
|
4
5
|
createResponseSpillPublicationControl,
|
|
@@ -658,6 +659,21 @@ export function spillQueueSupersededSpillFor(id: string): ResponseSpillRef | und
|
|
|
658
659
|
return pendingResponseSpillById.get(id)?.supersededSpill;
|
|
659
660
|
}
|
|
660
661
|
|
|
662
|
+
/**
|
|
663
|
+
* File names an orphan sweep must keep: each queued job's superseded generation,
|
|
664
|
+
* its in-flight temp, and its destination — all still owned but not yet named by
|
|
665
|
+
* `states` or `pendingSpillUnlinks`.
|
|
666
|
+
*/
|
|
667
|
+
export function spillQueueReferencedSpillFileNames(): Set<string> {
|
|
668
|
+
const names = new Set<string>();
|
|
669
|
+
for (const job of pendingResponseSpills) {
|
|
670
|
+
if (job.supersededSpill) names.add(job.supersededSpill.fileName);
|
|
671
|
+
if (job.publicationControl.tempPath) names.add(basename(job.publicationControl.tempPath));
|
|
672
|
+
if (job.publicationControl.destinationPath) names.add(basename(job.publicationControl.destinationPath));
|
|
673
|
+
}
|
|
674
|
+
return names;
|
|
675
|
+
}
|
|
676
|
+
|
|
661
677
|
/** Test-only: release queued jobs and zero the queue-owned byte accounting. */
|
|
662
678
|
export function resetSpillQueueForTests(): void {
|
|
663
679
|
for (const id of [...pendingResponseSpillById.keys()]) cancelPendingResponseSpill(id);
|