codex-chatgpt-control 0.5.1-alpha.2 → 0.5.1-alpha.4
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/CHANGELOG.md +42 -0
- package/README.md +43 -0
- package/contracts/v1/fixtures/backend-capabilities.json +40 -1
- package/contracts/v1/fixtures/backend-compatibility.json +21 -0
- package/contracts/v1/fixtures/backend-version.json +6 -1
- package/contracts/v1/fixtures/download-blocked-by-browser.json +22 -0
- package/contracts/v1/fixtures/download-receipt-failed.json +22 -0
- package/contracts/v1/fixtures/download-receipt-timeout.json +22 -0
- package/contracts/v1/fixtures/journal-rpc-indeterminate.json +32 -0
- package/contracts/v1/fixtures/journal-rpc-unsupported-platform.json +32 -0
- package/contracts/v1/fixtures/journal-runtime-unavailable.json +32 -0
- package/contracts/v1/fixtures/operation-action-prepared-event.json +42 -0
- package/contracts/v1/fixtures/operation-action.json +13 -0
- package/contracts/v1/fixtures/operation-artifact-receipt.json +14 -0
- package/contracts/v1/fixtures/operation-artifact-transfer-intent-event.json +24 -0
- package/contracts/v1/fixtures/operation-artifact-transfer-receipt-event.json +26 -0
- package/contracts/v1/fixtures/operation-artifact-transfer-state.json +178 -0
- package/contracts/v1/fixtures/operation-blocker.json +10 -0
- package/contracts/v1/fixtures/operation-collect-request.json +17 -0
- package/contracts/v1/fixtures/operation-collect-result.json +42 -0
- package/contracts/v1/fixtures/operation-control-receipt.json +13 -0
- package/contracts/v1/fixtures/operation-control-request.json +18 -0
- package/contracts/v1/fixtures/operation-control-result.json +31 -0
- package/contracts/v1/fixtures/operation-event.json +18 -0
- package/contracts/v1/fixtures/operation-handle.json +10 -0
- package/contracts/v1/fixtures/operation-inspect-request.json +13 -0
- package/contracts/v1/fixtures/operation-inspect-result.json +205 -0
- package/contracts/v1/fixtures/operation-ownership-baseline-event.json +34 -0
- package/contracts/v1/fixtures/operation-receipt.json +32 -0
- package/contracts/v1/fixtures/operation-recovery-decision.json +7 -0
- package/contracts/v1/fixtures/operation-recovery-observation.json +12 -0
- package/contracts/v1/fixtures/operation-request.json +33 -0
- package/contracts/v1/fixtures/operation-state.json +144 -0
- package/contracts/v1/fixtures/operation-submission-witness-event.json +20 -0
- package/contracts/v1/fixtures/operation-submission-witness.json +11 -0
- package/contracts/v1/fixtures/operation-submit-result.json +16 -0
- package/contracts/v1/fixtures/operation-target-established-event.json +21 -0
- package/contracts/v1/manifest.json +185 -1
- package/contracts/v1/parity-suite.json +106 -8
- package/contracts/v1/schemas/backend-compatibility.schema.json +65 -0
- package/contracts/v1/schemas/backend-request.schema.json +8 -2
- package/contracts/v1/schemas/capabilities.schema.json +81 -1
- package/contracts/v1/schemas/manifest.schema.json +57 -0
- package/contracts/v1/schemas/operation-action.schema.json +155 -0
- package/contracts/v1/schemas/operation-artifact-receipt.schema.json +89 -0
- package/contracts/v1/schemas/operation-blocker.schema.json +70 -0
- package/contracts/v1/schemas/operation-collect-request.schema.json +46 -0
- package/contracts/v1/schemas/operation-collect-result.schema.json +74 -0
- package/contracts/v1/schemas/operation-control-receipt.schema.json +108 -0
- package/contracts/v1/schemas/operation-control-request.schema.json +111 -0
- package/contracts/v1/schemas/operation-control-result.schema.json +112 -0
- package/contracts/v1/schemas/operation-event.schema.json +756 -0
- package/contracts/v1/schemas/operation-handle.schema.json +55 -0
- package/contracts/v1/schemas/operation-inspect-request.schema.json +42 -0
- package/contracts/v1/schemas/operation-inspect-result.schema.json +1662 -0
- package/contracts/v1/schemas/operation-receipt.schema.json +153 -0
- package/contracts/v1/schemas/operation-recovery.schema.json +314 -0
- package/contracts/v1/schemas/operation-request.schema.json +158 -0
- package/contracts/v1/schemas/operation-state.schema.json +691 -0
- package/contracts/v1/schemas/operation-submission-witness.schema.json +48 -0
- package/contracts/v1/schemas/operation-submit-result.schema.json +94 -0
- package/contracts/v1/surface-drift-policy.json +50 -0
- package/contracts/v1/vectors/operation-request-digest-v1.json +43 -0
- package/dist/codex-chatgpt-control-backend.mjs +45882 -9361
- package/dist/codex-chatgpt-control-journal.mjs +5547 -0
- package/dist/codex-chatgpt-control-live-smoke.bundle.mjs +51677 -0
- package/dist/codex-chatgpt-control-release-canary.bundle.mjs +52104 -0
- package/dist/codex-chatgpt-control.bundle.mjs +48392 -10260
- package/dist/src/backend/client.d.ts +103 -2
- package/dist/src/backend/client.js +1436 -97
- package/dist/src/backend/compatibility.d.ts +12 -0
- package/dist/src/backend/compatibility.js +208 -0
- package/dist/src/backend/protocol.d.ts +84 -1
- package/dist/src/backend/protocol.js +31 -5
- package/dist/src/backend/runtime-identity.d.ts +10 -0
- package/dist/src/backend/runtime-identity.js +141 -0
- package/dist/src/backend/session.d.ts +10 -2
- package/dist/src/backend/session.js +478 -7
- package/dist/src/backend/stdio-server.d.ts +4 -1
- package/dist/src/backend/stdio-server.js +240 -28
- package/dist/src/browser/active-composer-file-input.d.ts +9 -0
- package/dist/src/browser/active-composer-file-input.js +33 -0
- package/dist/src/browser/attach.d.ts +4 -1
- package/dist/src/browser/attach.js +136 -28
- package/dist/src/browser/chatgpt-url.d.ts +2 -0
- package/dist/src/browser/chatgpt-url.js +9 -0
- package/dist/src/browser/downloads.d.ts +17 -1
- package/dist/src/browser/downloads.js +150 -28
- package/dist/src/browser/page-state.js +6 -0
- package/dist/src/client.d.ts +43 -0
- package/dist/src/client.js +1279 -59
- package/dist/src/commands/artifacts.js +6 -4
- package/dist/src/commands/chat-popover.d.ts +37 -0
- package/dist/src/commands/chat-popover.js +537 -0
- package/dist/src/commands/configuration.d.ts +5 -0
- package/dist/src/commands/configuration.js +134 -21
- package/dist/src/commands/context.js +10 -3
- package/dist/src/commands/doctor.d.ts +1 -1
- package/dist/src/commands/doctor.js +27 -4
- package/dist/src/commands/experience.d.ts +5 -0
- package/dist/src/commands/experience.js +104 -21
- package/dist/src/commands/files.js +14 -33
- package/dist/src/commands/modes.js +85 -21
- package/dist/src/commands/power-discovery.d.ts +130 -0
- package/dist/src/commands/power-discovery.js +669 -0
- package/dist/src/commands/project-sources.js +325 -31
- package/dist/src/commands/sequence.js +59 -32
- package/dist/src/commands/session.js +19 -6
- package/dist/src/commands/work.js +31 -0
- package/dist/src/dom/composer-text.d.ts +6 -0
- package/dist/src/dom/composer-text.js +85 -0
- package/dist/src/errors.d.ts +9 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +9 -0
- package/dist/src/operations/artifact-output.d.ts +69 -0
- package/dist/src/operations/artifact-output.js +1748 -0
- package/dist/src/operations/artifact-stream.d.ts +19 -0
- package/dist/src/operations/artifact-stream.js +35 -0
- package/dist/src/operations/artifact-transfer.d.ts +149 -0
- package/dist/src/operations/artifact-transfer.js +1312 -0
- package/dist/src/operations/browser-adapter.d.ts +167 -0
- package/dist/src/operations/browser-adapter.js +2103 -0
- package/dist/src/operations/browser-observation.d.ts +110 -0
- package/dist/src/operations/browser-observation.js +1256 -0
- package/dist/src/operations/browser-target.d.ts +105 -0
- package/dist/src/operations/browser-target.js +531 -0
- package/dist/src/operations/canonical.d.ts +4 -0
- package/dist/src/operations/canonical.js +412 -0
- package/dist/src/operations/chatgpt-runtime.d.ts +72 -0
- package/dist/src/operations/chatgpt-runtime.js +1232 -0
- package/dist/src/operations/client.d.ts +138 -0
- package/dist/src/operations/client.js +1145 -0
- package/dist/src/operations/collector.d.ts +195 -0
- package/dist/src/operations/collector.js +1039 -0
- package/dist/src/operations/configuration-routing.d.ts +4 -0
- package/dist/src/operations/configuration-routing.js +14 -0
- package/dist/src/operations/control.d.ts +372 -0
- package/dist/src/operations/control.js +1498 -0
- package/dist/src/operations/file-identity.d.ts +33 -0
- package/dist/src/operations/file-identity.js +153 -0
- package/dist/src/operations/handle.d.ts +19 -0
- package/dist/src/operations/handle.js +727 -0
- package/dist/src/operations/index.d.ts +25 -0
- package/dist/src/operations/index.js +25 -0
- package/dist/src/operations/journal-authority.d.ts +12 -0
- package/dist/src/operations/journal-authority.js +1 -0
- package/dist/src/operations/journal-rpc-client.d.ts +12 -0
- package/dist/src/operations/journal-rpc-client.js +225 -0
- package/dist/src/operations/journal-rpc-protocol.d.ts +32 -0
- package/dist/src/operations/journal-rpc-protocol.js +214 -0
- package/dist/src/operations/journal-rpc-server.d.ts +15 -0
- package/dist/src/operations/journal-rpc-server.js +272 -0
- package/dist/src/operations/journal.d.ts +143 -0
- package/dist/src/operations/journal.js +1957 -0
- package/dist/src/operations/production-attachments.d.ts +118 -0
- package/dist/src/operations/production-attachments.js +1035 -0
- package/dist/src/operations/production-chatgpt-artifacts.d.ts +74 -0
- package/dist/src/operations/production-chatgpt-artifacts.js +1146 -0
- package/dist/src/operations/production-chatgpt-attachments.d.ts +80 -0
- package/dist/src/operations/production-chatgpt-attachments.js +1639 -0
- package/dist/src/operations/production-configuration.d.ts +42 -0
- package/dist/src/operations/production-configuration.js +1544 -0
- package/dist/src/operations/production-primitives.d.ts +53 -0
- package/dist/src/operations/production-primitives.js +856 -0
- package/dist/src/operations/production-work-steer.d.ts +203 -0
- package/dist/src/operations/production-work-steer.js +1218 -0
- package/dist/src/operations/recovery.d.ts +80 -0
- package/dist/src/operations/recovery.js +174 -0
- package/dist/src/operations/runtime-adapter.d.ts +126 -0
- package/dist/src/operations/runtime-adapter.js +802 -0
- package/dist/src/operations/send-once.d.ts +224 -0
- package/dist/src/operations/send-once.js +1081 -0
- package/dist/src/operations/service.d.ts +286 -0
- package/dist/src/operations/service.js +2831 -0
- package/dist/src/operations/staging.d.ts +136 -0
- package/dist/src/operations/staging.js +630 -0
- package/dist/src/operations/state-machine.d.ts +29 -0
- package/dist/src/operations/state-machine.js +2047 -0
- package/dist/src/operations/submission.d.ts +423 -0
- package/dist/src/operations/submission.js +1676 -0
- package/dist/src/operations/transactional-chat-power.d.ts +17 -0
- package/dist/src/operations/transactional-chat-power.js +212 -0
- package/dist/src/operations/turn-ownership.d.ts +202 -0
- package/dist/src/operations/turn-ownership.js +700 -0
- package/dist/src/operations/types.d.ts +454 -0
- package/dist/src/operations/types.js +20 -0
- package/dist/src/operations/wire-requests.d.ts +21 -0
- package/dist/src/operations/wire-requests.js +396 -0
- package/dist/src/operations/wire-results.d.ts +97 -0
- package/dist/src/operations/wire-results.js +818 -0
- package/dist/src/runner/responses.d.ts +3 -1
- package/dist/src/runner/responses.js +19 -1
- package/dist/src/runner/result.js +227 -55
- package/dist/src/runner/types.d.ts +12 -0
- package/dist/src/runtime/command-routing.d.ts +149 -0
- package/dist/src/runtime/command-routing.js +431 -0
- package/dist/src/runtime/coordinated-browser.d.ts +24 -0
- package/dist/src/runtime/coordinated-browser.js +315 -0
- package/dist/src/runtime/coordinated-page.d.ts +41 -0
- package/dist/src/runtime/coordinated-page.js +651 -0
- package/dist/src/runtime/operation-context.d.ts +142 -0
- package/dist/src/runtime/operation-context.js +410 -0
- package/dist/src/runtime/runtime-session.d.ts +95 -0
- package/dist/src/runtime/runtime-session.js +314 -0
- package/dist/src/runtime/tab-coordinator.d.ts +200 -0
- package/dist/src/runtime/tab-coordinator.js +1200 -0
- package/dist/src/runtime/value-boundaries.d.ts +18 -0
- package/dist/src/runtime/value-boundaries.js +49 -0
- package/dist/src/safety/blockers.js +8 -1
- package/dist/src/safety/untrusted-output.js +2 -1
- package/dist/src/scripts/backend-server.js +4 -1
- package/dist/src/scripts/capture-surface-profile.js +7 -2
- package/dist/src/scripts/journal-server.d.ts +2 -0
- package/dist/src/scripts/journal-server.js +37 -0
- package/dist/src/scripts/live-smoke/harness.js +79 -7
- package/dist/src/scripts/live-smoke/scenarios.d.ts +42 -1
- package/dist/src/scripts/live-smoke/scenarios.js +147 -30
- package/dist/src/scripts/live-smoke/transactional.d.ts +17 -0
- package/dist/src/scripts/live-smoke/transactional.js +254 -0
- package/dist/src/scripts/live-smoke/types.d.ts +3 -0
- package/dist/src/scripts/release-canary-module.js +17 -4
- package/dist/src/types.d.ts +38 -1
- package/package.json +8 -3
- package/references/2026-08-16-transactional-operations.md +459 -0
- package/references/2026-09-06-journal-service.md +140 -0
- package/references/agents-runner.md +34 -0
- package/references/backend-protocol.md +176 -0
- package/references/python-parity.md +137 -0
- package/references/responses-adapter.md +19 -0
- package/references/streaming.md +6 -0
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { TextDecoder } from "node:util";
|
|
3
4
|
import { createChatGPTAgent } from "../runner/agent.js";
|
|
4
|
-
import {
|
|
5
|
+
import { OperationWireRequestError, validateOperationCollectRequest as validateWireCollectRequest, validateOperationControlRequest as validateWireControlRequest, validateOperationInspectRequest as validateWireInspectRequest, validateOperationSubmitRequest as validateWireSubmitRequest } from "../operations/wire-requests.js";
|
|
6
|
+
import { validateOperationCollectWireResult, validateOperationControlWireResult, validateOperationInspectWireResult, validateOperationSubmitWireResult } from "../operations/wire-results.js";
|
|
7
|
+
import { BACKEND_REQUEST_SCHEMA_VERSION, BACKEND_RESPONSE_SCHEMA_VERSION, BACKEND_EVENT_SCHEMA_VERSION, BACKEND_HELLO_COMMAND, BACKEND_CONTROL_REQUEST_ID_PREFIX, BACKEND_NDJSON_FRAME_LIMIT_BYTES, isValidBackendRequestId } from "./protocol.js";
|
|
8
|
+
import { blockedCompatibilityReport, compatibilityReportFromHello, compatibilityReportFromLegacy, validateBackendCompatibilityReport } from "./compatibility.js";
|
|
5
9
|
export class BackendClientError extends Error {
|
|
6
10
|
code;
|
|
7
11
|
recoverable;
|
|
@@ -14,29 +18,66 @@ export class BackendClientError extends Error {
|
|
|
14
18
|
}
|
|
15
19
|
export function createChatGPTBackendClient(transport) {
|
|
16
20
|
let nextRequestId = 0;
|
|
21
|
+
const requestIdPrefix = `req_${process.pid}_${randomUUID()}`;
|
|
22
|
+
const allocateRequestId = () => `${requestIdPrefix}_${++nextRequestId}`;
|
|
17
23
|
const request = async (command, payload = {}) => {
|
|
18
24
|
const response = await transport.request({
|
|
19
25
|
schemaVersion: BACKEND_REQUEST_SCHEMA_VERSION,
|
|
20
|
-
requestId:
|
|
26
|
+
requestId: allocateRequestId(),
|
|
21
27
|
command,
|
|
22
28
|
payload
|
|
23
29
|
});
|
|
24
30
|
return unwrapResponse(response);
|
|
25
31
|
};
|
|
32
|
+
const operations = {
|
|
33
|
+
submit: async (operationRequest) => {
|
|
34
|
+
validateOperationSubmitRequest(operationRequest);
|
|
35
|
+
return parseOperationResult(await request("operations.submit", operationRequest), validateOperationSubmitWireResult);
|
|
36
|
+
},
|
|
37
|
+
collect: async (operationRequest) => {
|
|
38
|
+
validateOperationCollectRequest(operationRequest);
|
|
39
|
+
return parseOperationResult(await request("operations.collect", operationRequest), validateOperationCollectWireResult);
|
|
40
|
+
},
|
|
41
|
+
inspect: async (operationRequest) => {
|
|
42
|
+
validateOperationInspectRequest(operationRequest);
|
|
43
|
+
const result = parseOperationResult(await request("operations.inspect", operationRequest), validateOperationInspectWireResult);
|
|
44
|
+
return attachOperationCompatibility(result, transport);
|
|
45
|
+
},
|
|
46
|
+
control: async (operationRequest) => {
|
|
47
|
+
validateOperationControlRequest(operationRequest);
|
|
48
|
+
return parseOperationResult(await request("operations.control", operationRequest), validateOperationControlWireResult);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const compatibility = () => {
|
|
52
|
+
const report = transport.getCompatibilityReport?.();
|
|
53
|
+
if (report === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
return validateBackendCompatibilityReport(report);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
26
62
|
const runner = {
|
|
27
63
|
run: (agent, input) => request("runner.run", { agent, input }),
|
|
28
64
|
plan: (agent, input) => request("runner.plan", { agent, input }),
|
|
29
|
-
stream: (agent, input) =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
65
|
+
stream: (agent, input) => {
|
|
66
|
+
const requestId = allocateRequestId();
|
|
67
|
+
return streamFromBackendEvents(transport.stream({
|
|
68
|
+
schemaVersion: BACKEND_REQUEST_SCHEMA_VERSION,
|
|
69
|
+
requestId,
|
|
70
|
+
command: "runner.stream",
|
|
71
|
+
payload: { agent, input }
|
|
72
|
+
}), () => transport.cancel?.(requestId));
|
|
73
|
+
}
|
|
35
74
|
};
|
|
36
75
|
return {
|
|
37
76
|
agent: config => createChatGPTAgent(config),
|
|
38
77
|
run: runner.run,
|
|
39
78
|
runner,
|
|
79
|
+
compatibility,
|
|
80
|
+
operations,
|
|
40
81
|
responses: {
|
|
41
82
|
create: args => request("responses.create", args)
|
|
42
83
|
},
|
|
@@ -53,7 +94,10 @@ export function createChatGPTBackendClient(transport) {
|
|
|
53
94
|
copyLatest: args => request("copyLatest", args ?? {}),
|
|
54
95
|
downloadLatest: args => request("downloadLatest", args),
|
|
55
96
|
runPlan: plan => request("runPlan", plan),
|
|
56
|
-
doctor:
|
|
97
|
+
doctor: async (args) => {
|
|
98
|
+
const result = await request("doctor", args ?? {});
|
|
99
|
+
return attachDoctorCompatibility(result, args, compatibility());
|
|
100
|
+
},
|
|
57
101
|
createReport: (result, args) => request("createReport", args === undefined ? { result } : { result, args }),
|
|
58
102
|
reports: {
|
|
59
103
|
create: (result, args) => request("reports.create", args === undefined ? { result } : { result, args }),
|
|
@@ -123,53 +167,235 @@ export function createChatGPTBackendClient(transport) {
|
|
|
123
167
|
};
|
|
124
168
|
}
|
|
125
169
|
const DEFAULT_BACKEND_TIMEOUT_MS = 600_000;
|
|
170
|
+
const DEFAULT_BACKEND_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
171
|
+
// One slot is reserved for the first caller while the transport performs its
|
|
172
|
+
// hello/legacy probes. A lower bound of two keeps that control route inside
|
|
173
|
+
// the aggregate bound instead of making the first request impossible.
|
|
174
|
+
const MIN_BACKEND_IN_FLIGHT_LIMIT = 2;
|
|
175
|
+
const DEFAULT_BACKEND_MAX_IN_FLIGHT = 256;
|
|
176
|
+
const DEFAULT_BACKEND_STREAM_QUEUE_LIMIT = 256;
|
|
177
|
+
const DEFAULT_BACKEND_STREAM_QUEUE_BYTES_LIMIT = 16 * 1024 * 1024;
|
|
178
|
+
const DEFAULT_BACKEND_WRITE_QUEUE_LIMIT = 256;
|
|
179
|
+
const DEFAULT_BACKEND_WRITE_QUEUE_BYTES_LIMIT = 16 * 1024 * 1024;
|
|
180
|
+
const DEFAULT_BACKEND_LATE_OUTPUT_GRACE_MS = 5_000;
|
|
181
|
+
const DEFAULT_BACKEND_TOMBSTONE_LIMIT = 256;
|
|
182
|
+
const DEFAULT_BACKEND_QUARANTINE_LIMIT = 256;
|
|
183
|
+
const MAX_BACKEND_BUFFER_LIMIT = 1_000_000;
|
|
184
|
+
const MAX_BACKEND_STREAM_QUEUE_BYTES_LIMIT = 64 * 1024 * 1024;
|
|
185
|
+
const MAX_BACKEND_WRITE_QUEUE_BYTES_LIMIT = 64 * 1024 * 1024;
|
|
186
|
+
const MAX_BACKEND_TIMER_MS = 2_147_483_647;
|
|
187
|
+
const MAX_BACKEND_IDENTITY_FIELD_LENGTH = 512;
|
|
188
|
+
const REQUIRED_NEGOTIATION_COMMANDS = [
|
|
189
|
+
"backend.hello",
|
|
190
|
+
"backend.version",
|
|
191
|
+
"backend.capabilities",
|
|
192
|
+
"backend.health",
|
|
193
|
+
"runner.run",
|
|
194
|
+
"runner.stream"
|
|
195
|
+
];
|
|
196
|
+
const LEGACY_HELLO_ERROR_CODES = new Set(["unknown_command"]);
|
|
126
197
|
export class StdioBackendTransport {
|
|
127
198
|
options;
|
|
128
199
|
child;
|
|
129
200
|
stdout;
|
|
130
201
|
pendingResponses = new Map();
|
|
131
202
|
pendingStreams = new Map();
|
|
132
|
-
|
|
203
|
+
waitingRequests = new Map();
|
|
204
|
+
waitingStreams = new Map();
|
|
205
|
+
// A caller route remains in this set while it waits for handshake or a
|
|
206
|
+
// legacy single-flight slot. Once promoted, activeRequestIds owns its
|
|
207
|
+
// admission through the terminal response/event.
|
|
208
|
+
waitingAdmissionIds = new Set();
|
|
209
|
+
activeRequestIds = new Set();
|
|
210
|
+
// Control routes are a subset of activeRequestIds. Keeping this explicit
|
|
211
|
+
// lets admission reserve virtual handshake headroom between sequential
|
|
212
|
+
// legacy probes while still using the full bound during an active probe.
|
|
213
|
+
activeControlRequestIds = new Set();
|
|
214
|
+
activeWrites = new Set();
|
|
215
|
+
writeQueueCount = 0;
|
|
216
|
+
writeQueueBytes = 0;
|
|
217
|
+
tombstones = new Map();
|
|
218
|
+
quarantinedRequestIds = new Map();
|
|
219
|
+
// Keep one lifecycle tail across child generations. A reset while an old
|
|
220
|
+
// stdin write is unresolved would orphan its queued line closures and let
|
|
221
|
+
// repeated recycle cycles accumulate memory outside the admission budget.
|
|
222
|
+
writeTail = Promise.resolve();
|
|
223
|
+
retiredWriteTail;
|
|
224
|
+
recycleBlockedByWriteTeardown = false;
|
|
225
|
+
legacyTail = Promise.resolve();
|
|
226
|
+
handshakeState = "unknown";
|
|
227
|
+
handshakePromise;
|
|
228
|
+
handshakeGeneration = 0;
|
|
229
|
+
requestIdPrefix = `transport_${process.pid}_${randomUUID()}`;
|
|
230
|
+
handshakeError;
|
|
231
|
+
compatibilityReport;
|
|
232
|
+
protocolQuarantined = false;
|
|
233
|
+
quarantineRecycleTimer;
|
|
234
|
+
tombstoneRecycleTimer;
|
|
235
|
+
stderrBytes = 0;
|
|
236
|
+
stderrTruncated = false;
|
|
237
|
+
closed = false;
|
|
133
238
|
constructor(options) {
|
|
134
239
|
this.options = options;
|
|
240
|
+
validateTransportOptions(options);
|
|
135
241
|
}
|
|
136
242
|
async request(request) {
|
|
137
243
|
const requestId = requireRequestId(request);
|
|
138
|
-
this.
|
|
244
|
+
this.reserveWaitingAdmission(requestId);
|
|
139
245
|
return new Promise((resolve, reject) => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
246
|
+
let settled = false;
|
|
247
|
+
const cancelWaiting = error => {
|
|
248
|
+
if (settled)
|
|
249
|
+
return false;
|
|
250
|
+
settled = true;
|
|
251
|
+
this.waitingRequests.delete(requestId);
|
|
252
|
+
this.releaseWaitingAdmission(requestId);
|
|
253
|
+
this.releaseRequestId(requestId, undefined);
|
|
144
254
|
reject(error);
|
|
145
|
-
|
|
255
|
+
return true;
|
|
256
|
+
};
|
|
257
|
+
this.waitingRequests.set(requestId, cancelWaiting);
|
|
258
|
+
void (async () => {
|
|
259
|
+
let legacyRelease;
|
|
260
|
+
try {
|
|
261
|
+
await this.ensureHandshake();
|
|
262
|
+
if (settled)
|
|
263
|
+
return;
|
|
264
|
+
this.promoteWaitingAdmission(requestId);
|
|
265
|
+
legacyRelease = isSingleFlightState(this.handshakeState)
|
|
266
|
+
? await this.acquireLegacySlot()
|
|
267
|
+
: undefined;
|
|
268
|
+
if (settled) {
|
|
269
|
+
legacyRelease?.();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.assertCanIssue();
|
|
273
|
+
this.waitingRequests.delete(requestId);
|
|
274
|
+
const response = await this.issueResponse(request, legacyRelease !== undefined);
|
|
275
|
+
legacyRelease?.();
|
|
276
|
+
if (settled)
|
|
277
|
+
return;
|
|
278
|
+
settled = true;
|
|
279
|
+
resolve(response);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (settled)
|
|
283
|
+
return;
|
|
284
|
+
settled = true;
|
|
285
|
+
this.waitingRequests.delete(requestId);
|
|
286
|
+
// If the request never reached stdin there is no late output to
|
|
287
|
+
// guard against, so release the reservation without a tombstone.
|
|
288
|
+
legacyRelease?.();
|
|
289
|
+
this.releaseWaitingAdmission(requestId);
|
|
290
|
+
this.releaseRequestId(requestId, undefined);
|
|
291
|
+
reject(error);
|
|
292
|
+
}
|
|
293
|
+
})();
|
|
146
294
|
});
|
|
147
295
|
}
|
|
148
296
|
stream(request) {
|
|
149
297
|
const requestId = requireRequestId(request);
|
|
150
|
-
this.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
298
|
+
const queue = new AsyncQueue(this.options.streamQueueLimit ?? DEFAULT_BACKEND_STREAM_QUEUE_LIMIT, () => {
|
|
299
|
+
this.cancel(requestId, new BackendClientError("backend_stream_iterator_closed", `Backend stream requestId ${requestId} was abandoned by its iterator.`, true));
|
|
300
|
+
}, this.options.streamQueueBytesLimit ?? DEFAULT_BACKEND_STREAM_QUEUE_BYTES_LIMIT);
|
|
301
|
+
try {
|
|
302
|
+
this.reserveWaitingAdmission(requestId);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
queue.fail(error);
|
|
306
|
+
return queue;
|
|
307
|
+
}
|
|
308
|
+
let settled = false;
|
|
309
|
+
const cancelWaiting = error => {
|
|
310
|
+
if (settled)
|
|
311
|
+
return false;
|
|
312
|
+
settled = true;
|
|
313
|
+
this.waitingStreams.delete(requestId);
|
|
314
|
+
this.releaseWaitingAdmission(requestId);
|
|
315
|
+
this.releaseRequestId(requestId, undefined);
|
|
316
|
+
queue.fail(error);
|
|
317
|
+
return true;
|
|
318
|
+
};
|
|
319
|
+
this.waitingStreams.set(requestId, cancelWaiting);
|
|
320
|
+
void Promise.resolve().then(() => this.ensureHandshake())
|
|
321
|
+
.then(() => {
|
|
322
|
+
if (settled)
|
|
323
|
+
return;
|
|
324
|
+
const legacyReleasePromise = isSingleFlightState(this.handshakeState)
|
|
325
|
+
? this.acquireLegacySlot()
|
|
326
|
+
: Promise.resolve(undefined);
|
|
327
|
+
return legacyReleasePromise.then(legacyRelease => {
|
|
328
|
+
if (settled) {
|
|
329
|
+
legacyRelease?.();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
this.promoteWaitingAdmission(requestId);
|
|
334
|
+
this.assertCanIssue();
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
legacyRelease?.();
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
this.waitingStreams.delete(requestId);
|
|
341
|
+
return this.issueStream(request, queue, legacyRelease);
|
|
342
|
+
});
|
|
343
|
+
})
|
|
344
|
+
.catch(error => {
|
|
345
|
+
if (settled)
|
|
346
|
+
return;
|
|
347
|
+
settled = true;
|
|
348
|
+
this.waitingStreams.delete(requestId);
|
|
349
|
+
this.releaseWaitingAdmission(requestId);
|
|
350
|
+
this.releaseRequestId(requestId, undefined);
|
|
156
351
|
queue.fail(error);
|
|
157
352
|
});
|
|
158
353
|
return queue;
|
|
159
354
|
}
|
|
355
|
+
cancel(requestId, reason) {
|
|
356
|
+
const cancellationError = reason ?? new BackendClientError("backend_request_cancelled", `Backend request ${requestId} was cancelled locally.`, true);
|
|
357
|
+
const waitingRequest = this.waitingRequests.get(requestId);
|
|
358
|
+
if (waitingRequest !== undefined)
|
|
359
|
+
return waitingRequest(cancellationError);
|
|
360
|
+
const waitingStream = this.waitingStreams.get(requestId);
|
|
361
|
+
if (waitingStream !== undefined)
|
|
362
|
+
return waitingStream(cancellationError);
|
|
363
|
+
const response = this.pendingResponses.get(requestId);
|
|
364
|
+
if (response !== undefined) {
|
|
365
|
+
const writeStarted = this.hasStartedWrite(requestId);
|
|
366
|
+
this.clearResponse(requestId, true);
|
|
367
|
+
response.reject(cancellationError);
|
|
368
|
+
if (writeStarted)
|
|
369
|
+
this.terminate(cancellationError);
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
const stream = this.pendingStreams.get(requestId);
|
|
373
|
+
if (stream !== undefined) {
|
|
374
|
+
const writeStarted = this.hasStartedWrite(requestId);
|
|
375
|
+
this.clearStream(requestId, true);
|
|
376
|
+
stream.queue.fail(cancellationError);
|
|
377
|
+
if (writeStarted)
|
|
378
|
+
this.terminate(cancellationError);
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
160
383
|
async close() {
|
|
384
|
+
this.closed = true;
|
|
161
385
|
const child = this.child;
|
|
162
|
-
if (child === undefined)
|
|
386
|
+
if (child === undefined) {
|
|
387
|
+
this.failAll(new BackendClientError("backend_closed", "Backend transport was closed.", true));
|
|
163
388
|
return;
|
|
164
|
-
|
|
165
|
-
this.
|
|
166
|
-
this.stdout = undefined;
|
|
167
|
-
child.removeAllListeners("error");
|
|
168
|
-
child.removeAllListeners("exit");
|
|
169
|
-
child.kill();
|
|
170
|
-
this.failAll(new BackendClientError("backend_closed", "Backend transport was closed.", true));
|
|
389
|
+
}
|
|
390
|
+
this.terminate(new BackendClientError("backend_closed", "Backend transport was closed.", true), child);
|
|
171
391
|
}
|
|
172
392
|
start() {
|
|
393
|
+
if (this.closed) {
|
|
394
|
+
throw new BackendClientError("backend_closed", "Backend transport is closed.", true);
|
|
395
|
+
}
|
|
396
|
+
if (this.recycleBlockedByWriteTeardown) {
|
|
397
|
+
throw new BackendClientError("backend_write_teardown_pending", "Backend transport cannot start a new child while a previous stdin write is unresolved.", true);
|
|
398
|
+
}
|
|
173
399
|
if (this.child !== undefined)
|
|
174
400
|
return;
|
|
175
401
|
const [command, ...args] = this.options.command;
|
|
@@ -182,45 +408,344 @@ export class StdioBackendTransport {
|
|
|
182
408
|
stdio: ["pipe", "pipe", "pipe"]
|
|
183
409
|
});
|
|
184
410
|
this.child = child;
|
|
185
|
-
this.
|
|
186
|
-
this.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
411
|
+
this.handshakeState = "unknown";
|
|
412
|
+
this.handshakeError = undefined;
|
|
413
|
+
this.compatibilityReport = undefined;
|
|
414
|
+
// A new child generation starts before its first hello route is charged.
|
|
415
|
+
// Normal teardown calls failAll(), but keep the control subset explicit
|
|
416
|
+
// here as a defensive reset for a child that never reached that path.
|
|
417
|
+
this.activeControlRequestIds.clear();
|
|
418
|
+
this.protocolQuarantined = false;
|
|
419
|
+
this.clearQuarantineRecycleTimer();
|
|
420
|
+
this.clearTombstoneRecycleTimer();
|
|
421
|
+
this.tombstones.clear();
|
|
422
|
+
this.quarantinedRequestIds.clear();
|
|
423
|
+
// Do not reset writeTail here. If an old child has not settled its stdin
|
|
424
|
+
// callback yet, new-generation writes remain bounded behind that one
|
|
425
|
+
// lifecycle tail instead of creating an untracked queue.
|
|
426
|
+
this.legacyTail = Promise.resolve();
|
|
427
|
+
this.stderrBytes = 0;
|
|
428
|
+
this.stderrTruncated = false;
|
|
429
|
+
this.stdout = child.stdout;
|
|
430
|
+
void this.readStdout(child);
|
|
191
431
|
child.stderr.setEncoding("utf8");
|
|
192
432
|
child.stderr.on("data", chunk => {
|
|
193
433
|
if (this.child !== child)
|
|
194
434
|
return;
|
|
195
|
-
|
|
435
|
+
const bytes = Buffer.byteLength(String(chunk));
|
|
436
|
+
this.stderrBytes = Math.min(MAX_BACKEND_BUFFER_LIMIT, this.stderrBytes + bytes);
|
|
437
|
+
if (this.stderrBytes >= MAX_BACKEND_BUFFER_LIMIT)
|
|
438
|
+
this.stderrTruncated = true;
|
|
196
439
|
});
|
|
197
440
|
child.on("error", error => {
|
|
198
441
|
if (this.child !== child)
|
|
199
442
|
return;
|
|
200
|
-
this.child
|
|
201
|
-
this.stdout?.close();
|
|
202
|
-
this.stdout = undefined;
|
|
203
|
-
this.failAll(error);
|
|
443
|
+
this.handleProcessFailure(child, error);
|
|
204
444
|
});
|
|
205
445
|
child.on("exit", (code, signal) => {
|
|
206
446
|
if (this.child !== child)
|
|
207
447
|
return;
|
|
208
|
-
const suffix = this.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
this.
|
|
212
|
-
|
|
448
|
+
const suffix = this.stderrBytes > 0
|
|
449
|
+
? ` stderr_present=true stderr_bytes=${this.stderrBytes}${this.stderrTruncated ? " stderr_truncated=true" : ""}`
|
|
450
|
+
: "";
|
|
451
|
+
this.handleProcessFailure(child, new BackendClientError("backend_exited", `Backend process exited with code ${String(code)} signal ${String(signal)}.${suffix}`, true));
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
async readStdout(child) {
|
|
455
|
+
try {
|
|
456
|
+
for await (const line of readBoundedNdjsonLines(child.stdout, this.frameLimitBytes())) {
|
|
457
|
+
if (this.child !== child)
|
|
458
|
+
return;
|
|
459
|
+
this.handleLine(line);
|
|
460
|
+
}
|
|
461
|
+
// Node emits the child exit event after stdout closes in the normal
|
|
462
|
+
// process-failure path; let that authoritative lifecycle signal carry
|
|
463
|
+
// the public error instead of racing it with a synthetic EOF failure.
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
if (this.child !== child)
|
|
467
|
+
return;
|
|
468
|
+
const protocolError = error instanceof BackendFrameError
|
|
469
|
+
? new BackendClientError(error.code, error.message, true)
|
|
470
|
+
: new BackendClientError("invalid_backend_framing", "Backend stdout framing failed.", true);
|
|
471
|
+
this.terminate(protocolError, child);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
ensureHandshake() {
|
|
475
|
+
if (this.closed) {
|
|
476
|
+
return Promise.reject(new BackendClientError("backend_closed", "Backend transport is closed.", true));
|
|
477
|
+
}
|
|
478
|
+
if (this.handshakeState === "ready" || isSingleFlightState(this.handshakeState))
|
|
479
|
+
return Promise.resolve();
|
|
480
|
+
if (this.handshakeState === "blocked") {
|
|
481
|
+
return Promise.reject(this.handshakeError ?? new BackendClientError("backend_hello_rejected", "Backend hello negotiation has blocked this backend transport.", false));
|
|
482
|
+
}
|
|
483
|
+
if (this.handshakePromise !== undefined)
|
|
484
|
+
return this.handshakePromise;
|
|
485
|
+
this.start();
|
|
486
|
+
const requestId = `${BACKEND_CONTROL_REQUEST_ID_PREFIX}${this.requestIdPrefix}_hello_${++this.handshakeGeneration}`;
|
|
487
|
+
this.reserveRequestId(requestId, true);
|
|
488
|
+
const request = {
|
|
489
|
+
schemaVersion: BACKEND_REQUEST_SCHEMA_VERSION,
|
|
490
|
+
requestId,
|
|
491
|
+
command: BACKEND_HELLO_COMMAND,
|
|
492
|
+
payload: {
|
|
493
|
+
protocolVersion: BACKEND_REQUEST_SCHEMA_VERSION,
|
|
494
|
+
capabilities: {
|
|
495
|
+
commands: [...REQUIRED_NEGOTIATION_COMMANDS],
|
|
496
|
+
transports: ["stdio"],
|
|
497
|
+
streaming: { modes: ["ndjson"], tokenDeltas: false },
|
|
498
|
+
supportedProtocolVersions: [BACKEND_REQUEST_SCHEMA_VERSION],
|
|
499
|
+
requestIds: { required: true, scope: "connection" },
|
|
500
|
+
multiplexing: { unary: true, streams: true },
|
|
501
|
+
cancellation: { supported: false, requests: false, streams: false },
|
|
502
|
+
tabs: {
|
|
503
|
+
stableProviderIdentity: false,
|
|
504
|
+
stableBrowserIdentity: false,
|
|
505
|
+
stableTabIdentity: false,
|
|
506
|
+
coordinationScope: "none",
|
|
507
|
+
authoritativeClaim: false,
|
|
508
|
+
fencing: false,
|
|
509
|
+
concurrentTabs: false,
|
|
510
|
+
stableIdentity: false,
|
|
511
|
+
coordination: false,
|
|
512
|
+
concurrent: false
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
this.handshakePromise = this.issueResponse(request, true, true)
|
|
518
|
+
.then(response => {
|
|
519
|
+
if (!response.ok && LEGACY_HELLO_ERROR_CODES.has(response.error.code)) {
|
|
520
|
+
return this.negotiateLegacyBackend();
|
|
521
|
+
}
|
|
522
|
+
if (!response.ok) {
|
|
523
|
+
this.compatibilityReport = blockedCompatibilityReport();
|
|
524
|
+
throw new BackendClientError(response.error.code, response.error.message, response.error.recoverable);
|
|
525
|
+
}
|
|
526
|
+
if (!isNegotiatedHello(response.result, request.payload)) {
|
|
527
|
+
this.compatibilityReport = blockedCompatibilityReport();
|
|
528
|
+
throw new BackendClientError("backend_hello_rejected", "Backend hello negotiation was malformed or did not advertise the required transport capabilities.", false);
|
|
529
|
+
}
|
|
530
|
+
const multiplexed = negotiatedMultiplexing(response.result);
|
|
531
|
+
this.compatibilityReport = compatibilityReportFromHello(response.result, this.options.expectedIdentity, multiplexed ? "multiplexed" : "single-flight");
|
|
532
|
+
this.handshakeState = multiplexed ? "ready" : "single-flight";
|
|
533
|
+
})
|
|
534
|
+
.catch(error => {
|
|
535
|
+
if (error instanceof BackendClientError && error.code === "backend_hello_rejected") {
|
|
536
|
+
// A failed negotiation is not a usable legacy/modern route. Kill
|
|
537
|
+
// the sidecar before caching the rejection so stray output cannot
|
|
538
|
+
// keep a rejected process alive or be mistaken for a later session.
|
|
539
|
+
this.terminate(error);
|
|
540
|
+
this.handshakeError = error;
|
|
541
|
+
this.handshakeState = "blocked";
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
this.handshakeState = "unknown";
|
|
545
|
+
}
|
|
546
|
+
throw error;
|
|
547
|
+
})
|
|
548
|
+
.finally(() => {
|
|
549
|
+
this.handshakePromise = undefined;
|
|
550
|
+
this.maybeRecycleQuarantined();
|
|
551
|
+
});
|
|
552
|
+
return this.handshakePromise;
|
|
553
|
+
}
|
|
554
|
+
async negotiateLegacyBackend() {
|
|
555
|
+
const probes = new Map();
|
|
556
|
+
for (const command of ["backend.version", "backend.capabilities"]) {
|
|
557
|
+
const requestId = `${BACKEND_CONTROL_REQUEST_ID_PREFIX}${this.requestIdPrefix}_legacy_${++this.handshakeGeneration}`;
|
|
558
|
+
this.reserveRequestId(requestId, true);
|
|
559
|
+
const response = await this.issueResponse({
|
|
560
|
+
schemaVersion: BACKEND_REQUEST_SCHEMA_VERSION,
|
|
561
|
+
requestId,
|
|
562
|
+
command,
|
|
563
|
+
payload: {}
|
|
564
|
+
}, true, true);
|
|
565
|
+
if (!response.ok) {
|
|
566
|
+
throw new BackendClientError("backend_hello_rejected", `Legacy backend ${command} probe did not return a successful compatible result.`, false);
|
|
567
|
+
}
|
|
568
|
+
probes.set(command, response);
|
|
569
|
+
}
|
|
570
|
+
const versionProbe = probes.get("backend.version");
|
|
571
|
+
const capabilitiesProbe = probes.get("backend.capabilities");
|
|
572
|
+
const version = versionProbe?.ok === true ? versionProbe.result : undefined;
|
|
573
|
+
const capabilities = capabilitiesProbe?.ok === true ? capabilitiesProbe.result : undefined;
|
|
574
|
+
if (!isCompatibleLegacyVersion(version) || !isCompatibleLegacyCapabilities(capabilities)) {
|
|
575
|
+
this.compatibilityReport = blockedCompatibilityReport();
|
|
576
|
+
throw new BackendClientError("backend_hello_rejected", "Legacy backend probes did not advertise a compatible protocol and command set.", false);
|
|
577
|
+
}
|
|
578
|
+
this.compatibilityReport = compatibilityReportFromLegacy(version, this.options.expectedIdentity);
|
|
579
|
+
this.handshakeState = "legacy";
|
|
580
|
+
}
|
|
581
|
+
getCompatibilityReport() {
|
|
582
|
+
return this.compatibilityReport;
|
|
583
|
+
}
|
|
584
|
+
issueResponse(request, fatalOnTimeout, handshake = false) {
|
|
585
|
+
const requestId = requireRequestId(request);
|
|
586
|
+
return new Promise((resolve, reject) => {
|
|
587
|
+
const timeout = this.createDeadline(requestId, fatalOnTimeout, handshake);
|
|
588
|
+
this.pendingResponses.set(requestId, { resolve, reject, timeout, fatalOnTimeout });
|
|
589
|
+
void this.write(request, handshake).catch(error => {
|
|
590
|
+
const pending = this.pendingResponses.get(requestId);
|
|
591
|
+
if (pending === undefined)
|
|
592
|
+
return;
|
|
593
|
+
this.clearResponse(requestId, !isDefinitelyUnsentWriteError(error));
|
|
594
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
async issueStream(request, queue, legacyRelease) {
|
|
599
|
+
const requestId = requireRequestId(request);
|
|
600
|
+
const timeout = this.createDeadline(requestId, legacyRelease !== undefined, false);
|
|
601
|
+
this.pendingStreams.set(requestId, {
|
|
602
|
+
queue,
|
|
603
|
+
timeout,
|
|
604
|
+
fatalOnTimeout: legacyRelease !== undefined,
|
|
605
|
+
...(legacyRelease === undefined ? {} : { legacyRelease })
|
|
213
606
|
});
|
|
607
|
+
try {
|
|
608
|
+
await this.write(request);
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
const pending = this.pendingStreams.get(requestId);
|
|
612
|
+
if (pending !== undefined) {
|
|
613
|
+
this.clearStream(requestId, !isDefinitelyUnsentWriteError(error));
|
|
614
|
+
pending.queue.fail(error instanceof Error ? error : new Error(String(error)));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
214
617
|
}
|
|
215
|
-
write(request,
|
|
618
|
+
write(request, control = false) {
|
|
619
|
+
const requestId = requireRequestId(request);
|
|
216
620
|
const child = this.child;
|
|
217
|
-
if (child === undefined) {
|
|
218
|
-
reject(new BackendClientError("
|
|
621
|
+
if (child === undefined || this.closed) {
|
|
622
|
+
return Promise.reject(new BackendClientError("backend_closed", "Backend process is not running.", true));
|
|
623
|
+
}
|
|
624
|
+
let line;
|
|
625
|
+
try {
|
|
626
|
+
line = `${JSON.stringify(request)}\n`;
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
return Promise.reject(new BackendClientError("invalid_backend_request", "Backend request could not be encoded as JSON.", false));
|
|
630
|
+
}
|
|
631
|
+
if (Buffer.byteLength(line, "utf8") > this.frameLimitBytes()) {
|
|
632
|
+
return Promise.reject(new BackendClientError("backend_frame_too_large", `Backend request frame exceeds the ${this.frameLimitBytes()} byte limit.`, false));
|
|
633
|
+
}
|
|
634
|
+
let admission;
|
|
635
|
+
try {
|
|
636
|
+
admission = this.admitWrite(requestId, line, child);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
640
|
+
}
|
|
641
|
+
const next = this.writeTail.then(() => {
|
|
642
|
+
if (admission.released) {
|
|
643
|
+
throw new BackendClientError("backend_request_cancelled", `Backend request ${requestId} was cancelled before it could be written.`, true);
|
|
644
|
+
}
|
|
645
|
+
if (!control)
|
|
646
|
+
this.assertCanIssue();
|
|
647
|
+
if (this.child !== admission.child) {
|
|
648
|
+
throw new BackendClientError("backend_closed", "Backend process is not running.", true);
|
|
649
|
+
}
|
|
650
|
+
if (!this.isWriteRouteActive(requestId)) {
|
|
651
|
+
throw new BackendClientError("backend_request_cancelled", `Backend request ${requestId} was cancelled before it could be written.`, true);
|
|
652
|
+
}
|
|
653
|
+
admission.started = true;
|
|
654
|
+
return this.writeLine(child, line).finally(() => this.releaseWrite(admission));
|
|
655
|
+
}).catch(error => {
|
|
656
|
+
this.releaseWrite(admission);
|
|
657
|
+
throw error;
|
|
658
|
+
});
|
|
659
|
+
this.writeTail = next.catch(() => { });
|
|
660
|
+
return next;
|
|
661
|
+
}
|
|
662
|
+
admitWrite(requestId, line, child) {
|
|
663
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
664
|
+
const countLimit = this.options.writeQueueLimit ?? DEFAULT_BACKEND_WRITE_QUEUE_LIMIT;
|
|
665
|
+
const bytesLimit = this.options.writeQueueBytesLimit ?? DEFAULT_BACKEND_WRITE_QUEUE_BYTES_LIMIT;
|
|
666
|
+
if (this.writeQueueCount >= countLimit || this.writeQueueBytes > bytesLimit - bytes) {
|
|
667
|
+
throw new BackendClientError("backend_write_queue_overflow", "Backend outbound request buffering exceeded its bounded limit.", true);
|
|
668
|
+
}
|
|
669
|
+
const admission = { requestId, bytes, child, started: false, released: false };
|
|
670
|
+
this.activeWrites.add(admission);
|
|
671
|
+
this.writeQueueCount += 1;
|
|
672
|
+
this.writeQueueBytes += bytes;
|
|
673
|
+
return admission;
|
|
674
|
+
}
|
|
675
|
+
releaseWrite(admission) {
|
|
676
|
+
if (admission.released)
|
|
677
|
+
return;
|
|
678
|
+
admission.released = true;
|
|
679
|
+
if (!this.activeWrites.delete(admission))
|
|
680
|
+
return;
|
|
681
|
+
this.writeQueueCount = Math.max(0, this.writeQueueCount - 1);
|
|
682
|
+
this.writeQueueBytes = Math.max(0, this.writeQueueBytes - admission.bytes);
|
|
683
|
+
this.maybeUnblockWriteTeardown();
|
|
684
|
+
}
|
|
685
|
+
retireWriteLifecycle(retiredChild) {
|
|
686
|
+
if (![...this.activeWrites].some(admission => admission.child === retiredChild))
|
|
687
|
+
return;
|
|
688
|
+
if (this.retiredWriteTail === undefined) {
|
|
689
|
+
const retiredTail = this.writeTail;
|
|
690
|
+
this.retiredWriteTail = retiredTail;
|
|
691
|
+
// Detach this child generation so a replacement child never inherits a
|
|
692
|
+
// permanently blocked stdin callback. The detached tail remains bounded
|
|
693
|
+
// by the charged admissions until its callbacks settle.
|
|
694
|
+
this.writeTail = Promise.resolve();
|
|
695
|
+
void retiredTail.then(() => this.finishRetiredWriteTail(retiredTail), () => this.finishRetiredWriteTail(retiredTail));
|
|
219
696
|
return;
|
|
220
697
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
698
|
+
// A second recycle while the detached generation is still unresolved may
|
|
699
|
+
// not create another orphaned tail. Leave the current tail attached and
|
|
700
|
+
// fail closed until both generations settle.
|
|
701
|
+
this.recycleBlockedByWriteTeardown = true;
|
|
702
|
+
void this.writeTail.then(() => this.maybeUnblockWriteTeardown(), () => this.maybeUnblockWriteTeardown());
|
|
703
|
+
}
|
|
704
|
+
finishRetiredWriteTail(retiredTail) {
|
|
705
|
+
if (this.retiredWriteTail === retiredTail)
|
|
706
|
+
this.retiredWriteTail = undefined;
|
|
707
|
+
this.maybeUnblockWriteTeardown();
|
|
708
|
+
}
|
|
709
|
+
maybeUnblockWriteTeardown() {
|
|
710
|
+
if (!this.recycleBlockedByWriteTeardown)
|
|
711
|
+
return;
|
|
712
|
+
if (this.retiredWriteTail !== undefined || this.activeWrites.size > 0)
|
|
713
|
+
return;
|
|
714
|
+
this.recycleBlockedByWriteTeardown = false;
|
|
715
|
+
}
|
|
716
|
+
isWriteRouteActive(requestId) {
|
|
717
|
+
return this.pendingResponses.has(requestId) || this.pendingStreams.has(requestId);
|
|
718
|
+
}
|
|
719
|
+
hasStartedWrite(requestId) {
|
|
720
|
+
return [...this.activeWrites].some(admission => admission.requestId === requestId && admission.started);
|
|
721
|
+
}
|
|
722
|
+
writeLine(child, line) {
|
|
723
|
+
if (this.child !== child) {
|
|
724
|
+
return Promise.reject(new BackendClientError("backend_closed", "Backend process is not running.", true));
|
|
725
|
+
}
|
|
726
|
+
if (Buffer.byteLength(line, "utf8") > this.frameLimitBytes()) {
|
|
727
|
+
return Promise.reject(new BackendClientError("backend_frame_too_large", `Backend request frame exceeds the ${this.frameLimitBytes()} byte limit.`, false));
|
|
728
|
+
}
|
|
729
|
+
return new Promise((resolve, reject) => {
|
|
730
|
+
let settled = false;
|
|
731
|
+
const onError = (error) => finish(error);
|
|
732
|
+
const finish = (error) => {
|
|
733
|
+
if (settled)
|
|
734
|
+
return;
|
|
735
|
+
settled = true;
|
|
736
|
+
child.stdin.off("error", onError);
|
|
737
|
+
if (error !== undefined && error !== null)
|
|
738
|
+
reject(error);
|
|
739
|
+
else
|
|
740
|
+
resolve();
|
|
741
|
+
};
|
|
742
|
+
child.stdin.once("error", onError);
|
|
743
|
+
try {
|
|
744
|
+
child.stdin.write(line, error => finish(error));
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
748
|
+
}
|
|
224
749
|
});
|
|
225
750
|
}
|
|
226
751
|
handleLine(line) {
|
|
@@ -228,82 +753,101 @@ export class StdioBackendTransport {
|
|
|
228
753
|
try {
|
|
229
754
|
value = JSON.parse(line);
|
|
230
755
|
}
|
|
231
|
-
catch
|
|
232
|
-
this.
|
|
756
|
+
catch {
|
|
757
|
+
this.terminate(new BackendClientError("invalid_backend_json", "Backend emitted an invalid JSON frame.", true));
|
|
233
758
|
return;
|
|
234
759
|
}
|
|
235
760
|
if (!isRecord(value)) {
|
|
236
|
-
this.
|
|
761
|
+
this.terminate(new BackendClientError("invalid_backend_message", "Backend protocol line must be a JSON object.", true));
|
|
237
762
|
return;
|
|
238
763
|
}
|
|
239
764
|
if (value.schemaVersion === BACKEND_RESPONSE_SCHEMA_VERSION) {
|
|
240
|
-
|
|
765
|
+
try {
|
|
766
|
+
this.handleResponse(parseBackendResponseMessage(value));
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
this.terminate(asProtocolClientError(error));
|
|
770
|
+
}
|
|
241
771
|
return;
|
|
242
772
|
}
|
|
243
773
|
if (value.schemaVersion === BACKEND_EVENT_SCHEMA_VERSION) {
|
|
244
|
-
|
|
774
|
+
try {
|
|
775
|
+
this.handleEvent(parseBackendEventMessage(value));
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
this.terminate(asProtocolClientError(error));
|
|
779
|
+
}
|
|
245
780
|
return;
|
|
246
781
|
}
|
|
247
|
-
this.
|
|
782
|
+
this.terminate(new BackendClientError("unsupported_backend_schema", "Backend emitted an unsupported protocol schema.", true));
|
|
248
783
|
}
|
|
249
784
|
handleResponse(response) {
|
|
250
785
|
const requestId = response.requestId;
|
|
251
786
|
if (requestId === undefined) {
|
|
252
|
-
this.
|
|
787
|
+
this.terminate(new BackendClientError("missing_backend_request_id", "Backend response is missing requestId.", true));
|
|
253
788
|
return;
|
|
254
789
|
}
|
|
255
790
|
const pending = this.pendingResponses.get(requestId);
|
|
256
791
|
if (pending === undefined) {
|
|
257
792
|
const stream = this.pendingStreams.get(requestId);
|
|
258
793
|
if (stream !== undefined) {
|
|
259
|
-
|
|
260
|
-
this.clearStream(requestId);
|
|
794
|
+
this.terminate(new BackendClientError("unexpected_backend_response", `Backend sent a response for streaming requestId ${requestId}.`, true));
|
|
261
795
|
return;
|
|
262
796
|
}
|
|
263
|
-
this.
|
|
797
|
+
if (this.consumeTombstoneResponse(requestId))
|
|
798
|
+
return;
|
|
799
|
+
this.discardLateOrQuarantine(requestId);
|
|
264
800
|
return;
|
|
265
801
|
}
|
|
266
802
|
if (typeof response.ok !== "boolean") {
|
|
267
|
-
this.clearResponse(requestId);
|
|
803
|
+
this.clearResponse(requestId, true);
|
|
268
804
|
pending.reject(new BackendClientError("invalid_backend_response", `Backend response for requestId ${requestId} is missing boolean ok.`, true));
|
|
269
805
|
return;
|
|
270
806
|
}
|
|
271
|
-
this.clearResponse(requestId);
|
|
807
|
+
this.clearResponse(requestId, false);
|
|
272
808
|
pending.resolve(response);
|
|
273
809
|
}
|
|
274
810
|
handleEvent(event) {
|
|
275
811
|
const requestId = event.requestId;
|
|
276
812
|
if (requestId === undefined) {
|
|
277
|
-
this.
|
|
813
|
+
this.terminate(new BackendClientError("missing_backend_request_id", "Backend event is missing requestId.", true));
|
|
278
814
|
return;
|
|
279
815
|
}
|
|
280
816
|
const pending = this.pendingStreams.get(requestId);
|
|
281
817
|
if (pending === undefined) {
|
|
282
818
|
const response = this.pendingResponses.get(requestId);
|
|
283
819
|
if (response !== undefined) {
|
|
284
|
-
this.
|
|
285
|
-
response.reject(new BackendClientError("unexpected_backend_event", `Backend sent an event for non-streaming requestId ${requestId}.`, true));
|
|
820
|
+
this.terminate(new BackendClientError("unexpected_backend_event", `Backend sent an event for non-streaming requestId ${requestId}.`, true));
|
|
286
821
|
return;
|
|
287
822
|
}
|
|
288
|
-
this.
|
|
823
|
+
if (this.consumeTombstoneEvent(requestId, event.type))
|
|
824
|
+
return;
|
|
825
|
+
this.discardLateOrQuarantine(requestId);
|
|
289
826
|
return;
|
|
290
827
|
}
|
|
291
828
|
if (typeof event.type !== "string") {
|
|
292
829
|
pending.queue.fail(new BackendClientError("invalid_backend_event", `Backend event for requestId ${requestId} is missing type.`, true));
|
|
293
|
-
this.clearStream(requestId);
|
|
830
|
+
this.clearStream(requestId, true);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (!pending.queue.push(event)) {
|
|
834
|
+
pending.queue.fail(new BackendClientError("backend_stream_overflow", `Backend stream requestId ${requestId} exceeded its bounded event queue.`, true));
|
|
835
|
+
this.clearStream(requestId, true);
|
|
294
836
|
return;
|
|
295
837
|
}
|
|
296
|
-
pending.queue.push(event);
|
|
297
838
|
if (event.type === "completed") {
|
|
298
839
|
pending.queue.finish();
|
|
299
|
-
this.clearStream(requestId);
|
|
840
|
+
this.clearStream(requestId, false);
|
|
300
841
|
}
|
|
301
842
|
if (event.type === "error") {
|
|
302
843
|
pending.queue.fail(new BackendClientError(event.error.code, event.error.message, event.error.recoverable));
|
|
303
|
-
this.clearStream(requestId);
|
|
844
|
+
this.clearStream(requestId, false);
|
|
304
845
|
}
|
|
305
846
|
}
|
|
306
847
|
failAll(error) {
|
|
848
|
+
// Keep active write admissions charged until their tail entries settle.
|
|
849
|
+
// Clearing them here would make a blocked old-generation tail invisible
|
|
850
|
+
// to the next generation and defeat the aggregate memory bound.
|
|
307
851
|
for (const pending of this.pendingResponses.values()) {
|
|
308
852
|
clearTimeout(pending.timeout);
|
|
309
853
|
pending.reject(error);
|
|
@@ -312,47 +856,448 @@ export class StdioBackendTransport {
|
|
|
312
856
|
for (const pending of this.pendingStreams.values()) {
|
|
313
857
|
clearTimeout(pending.timeout);
|
|
314
858
|
pending.queue.fail(error);
|
|
859
|
+
pending.legacyRelease?.();
|
|
315
860
|
}
|
|
316
861
|
this.pendingStreams.clear();
|
|
862
|
+
for (const cancelWaiting of this.waitingRequests.values())
|
|
863
|
+
cancelWaiting(error);
|
|
864
|
+
this.waitingRequests.clear();
|
|
865
|
+
for (const cancelWaiting of this.waitingStreams.values())
|
|
866
|
+
cancelWaiting(error);
|
|
867
|
+
this.waitingStreams.clear();
|
|
868
|
+
this.waitingAdmissionIds.clear();
|
|
869
|
+
this.activeRequestIds.clear();
|
|
870
|
+
this.activeControlRequestIds.clear();
|
|
317
871
|
}
|
|
318
|
-
clearResponse(requestId) {
|
|
872
|
+
clearResponse(requestId, tombstone) {
|
|
319
873
|
const pending = this.pendingResponses.get(requestId);
|
|
320
874
|
if (pending !== undefined) {
|
|
321
875
|
clearTimeout(pending.timeout);
|
|
322
876
|
this.pendingResponses.delete(requestId);
|
|
877
|
+
this.releaseRequestId(requestId, tombstone ? "unary" : undefined);
|
|
878
|
+
this.maybeRecycleQuarantined();
|
|
323
879
|
}
|
|
324
880
|
}
|
|
325
|
-
clearStream(requestId) {
|
|
881
|
+
clearStream(requestId, tombstone) {
|
|
326
882
|
const pending = this.pendingStreams.get(requestId);
|
|
327
883
|
if (pending !== undefined) {
|
|
328
884
|
clearTimeout(pending.timeout);
|
|
329
885
|
this.pendingStreams.delete(requestId);
|
|
886
|
+
pending.legacyRelease?.();
|
|
887
|
+
this.releaseRequestId(requestId, tombstone ? "stream" : undefined);
|
|
888
|
+
this.maybeRecycleQuarantined();
|
|
330
889
|
}
|
|
331
890
|
}
|
|
332
|
-
createDeadline(requestId) {
|
|
333
|
-
const timeoutMs =
|
|
891
|
+
createDeadline(requestId, fatalOnTimeout, handshake) {
|
|
892
|
+
const timeoutMs = handshake
|
|
893
|
+
? this.options.handshakeTimeoutMs ?? DEFAULT_BACKEND_HANDSHAKE_TIMEOUT_MS
|
|
894
|
+
: this.options.timeoutMs ?? DEFAULT_BACKEND_TIMEOUT_MS;
|
|
334
895
|
return setTimeout(() => {
|
|
335
896
|
const error = new BackendClientError("backend_timeout", `Backend request ${requestId} timed out after ${timeoutMs}ms.`, true);
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
897
|
+
const response = this.pendingResponses.get(requestId);
|
|
898
|
+
if (response !== undefined) {
|
|
899
|
+
const writeStarted = this.hasStartedWrite(requestId);
|
|
900
|
+
this.clearResponse(requestId, true);
|
|
901
|
+
response.reject(error);
|
|
902
|
+
if (response.fatalOnTimeout || writeStarted)
|
|
903
|
+
this.terminate(error);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const stream = this.pendingStreams.get(requestId);
|
|
907
|
+
if (stream !== undefined) {
|
|
908
|
+
const writeStarted = this.hasStartedWrite(requestId);
|
|
909
|
+
this.clearStream(requestId, true);
|
|
910
|
+
stream.queue.fail(error);
|
|
911
|
+
if (stream.fatalOnTimeout || writeStarted)
|
|
912
|
+
this.terminate(error);
|
|
344
913
|
}
|
|
345
|
-
this.failAll(error);
|
|
346
914
|
}, timeoutMs);
|
|
347
915
|
}
|
|
916
|
+
acquireLegacySlot() {
|
|
917
|
+
const previous = this.legacyTail;
|
|
918
|
+
let releasePrevious;
|
|
919
|
+
this.legacyTail = new Promise(resolve => {
|
|
920
|
+
releasePrevious = resolve;
|
|
921
|
+
});
|
|
922
|
+
return previous.then(() => {
|
|
923
|
+
let released = false;
|
|
924
|
+
return () => {
|
|
925
|
+
if (released)
|
|
926
|
+
return;
|
|
927
|
+
released = true;
|
|
928
|
+
releasePrevious();
|
|
929
|
+
};
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
reserveWaitingAdmission(requestId) {
|
|
933
|
+
this.validateRequestId(requestId);
|
|
934
|
+
this.assertAdmissionCapacity(false);
|
|
935
|
+
this.waitingAdmissionIds.add(requestId);
|
|
936
|
+
}
|
|
937
|
+
releaseWaitingAdmission(requestId) {
|
|
938
|
+
this.waitingAdmissionIds.delete(requestId);
|
|
939
|
+
}
|
|
940
|
+
promoteWaitingAdmission(requestId) {
|
|
941
|
+
if (!this.waitingAdmissionIds.delete(requestId)) {
|
|
942
|
+
throw new BackendClientError("backend_request_cancelled", `Backend request ${requestId} was cancelled before it could be issued.`, true);
|
|
943
|
+
}
|
|
944
|
+
try {
|
|
945
|
+
this.reserveRequestId(requestId);
|
|
946
|
+
}
|
|
947
|
+
catch (error) {
|
|
948
|
+
// Keep the admission state truthful if promotion fails after the
|
|
949
|
+
// caller's waiting slot has been removed.
|
|
950
|
+
this.releaseRequestId(requestId, undefined);
|
|
951
|
+
throw error;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
reserveRequestId(requestId, control = false) {
|
|
955
|
+
this.pruneIdState();
|
|
956
|
+
this.validateRequestId(requestId, control);
|
|
957
|
+
this.assertAdmissionCapacity(control);
|
|
958
|
+
this.activeRequestIds.add(requestId);
|
|
959
|
+
if (control)
|
|
960
|
+
this.activeControlRequestIds.add(requestId);
|
|
961
|
+
}
|
|
962
|
+
validateRequestId(requestId, control = false) {
|
|
963
|
+
if (this.closed) {
|
|
964
|
+
throw new BackendClientError("backend_closed", "Backend transport is closed.", true);
|
|
965
|
+
}
|
|
966
|
+
if (!isValidBackendRequestId(requestId)) {
|
|
967
|
+
throw new BackendClientError("invalid_request_id", "Backend requestId must be a bounded, non-empty string without control characters.", false);
|
|
968
|
+
}
|
|
969
|
+
if (!control && requestId.startsWith(BACKEND_CONTROL_REQUEST_ID_PREFIX)) {
|
|
970
|
+
throw new BackendClientError("reserved_request_id", "Backend requestId uses a transport-reserved control namespace.", false);
|
|
971
|
+
}
|
|
972
|
+
if (this.protocolQuarantined) {
|
|
973
|
+
throw new BackendClientError("backend_protocol_quarantined", "Backend transport is quarantined after an unknown requestId; wait for it to recycle before sending new work.", true);
|
|
974
|
+
}
|
|
975
|
+
if (this.activeRequestIds.has(requestId)) {
|
|
976
|
+
throw new BackendClientError("duplicate_request_id", `Backend requestId ${requestId} is already active.`, false);
|
|
977
|
+
}
|
|
978
|
+
if (this.waitingAdmissionIds.has(requestId)) {
|
|
979
|
+
throw new BackendClientError("duplicate_request_id", `Backend requestId ${requestId} is already waiting for admission.`, false);
|
|
980
|
+
}
|
|
981
|
+
if (this.tombstones.has(requestId)) {
|
|
982
|
+
throw new BackendClientError("request_id_reused", `Backend requestId ${requestId} was recently completed or cancelled and cannot be reused yet.`, false);
|
|
983
|
+
}
|
|
984
|
+
if (this.quarantinedRequestIds.has(requestId)) {
|
|
985
|
+
throw new BackendClientError("request_id_quarantined", `Backend requestId ${requestId} was quarantined after an unknown backend message and cannot be reused yet.`, false);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
assertAdmissionCapacity(control = false) {
|
|
989
|
+
// Before the first handshake control route exists, leave one aggregate
|
|
990
|
+
// slot free for that route. This matters for streams, whose handshake is
|
|
991
|
+
// deliberately deferred to a microtask and can therefore have multiple
|
|
992
|
+
// callers reserved synchronously. The same virtual slot is restored
|
|
993
|
+
// between sequential legacy probes. Once a control route is active (or
|
|
994
|
+
// negotiation has completed), caller routes use the full configured bound.
|
|
995
|
+
const limit = !control
|
|
996
|
+
&& this.handshakeState === "unknown"
|
|
997
|
+
&& this.activeControlRequestIds.size === 0
|
|
998
|
+
? this.maxInFlight() - 1
|
|
999
|
+
: this.maxInFlight();
|
|
1000
|
+
if (this.waitingAdmissionIds.size + this.activeRequestIds.size >= limit) {
|
|
1001
|
+
throw new BackendClientError("backend_in_flight_limit", "Backend transport reached its bounded in-flight route limit.", true);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
releaseRequestId(requestId, tombstone) {
|
|
1005
|
+
this.activeRequestIds.delete(requestId);
|
|
1006
|
+
this.activeControlRequestIds.delete(requestId);
|
|
1007
|
+
if (tombstone === undefined)
|
|
1008
|
+
return;
|
|
1009
|
+
const kind = tombstone;
|
|
1010
|
+
if (this.tombstones.size >= this.tombstoneLimit() && !this.tombstones.has(requestId)) {
|
|
1011
|
+
this.terminate(new BackendClientError("backend_tombstone_limit", "Backend transport recycled because its late-output tombstone bound was reached.", true));
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
this.tombstones.set(requestId, {
|
|
1015
|
+
kind,
|
|
1016
|
+
expiresAt: Date.now() + this.lateOutputGraceMs()
|
|
1017
|
+
});
|
|
1018
|
+
this.scheduleTombstoneRecycle();
|
|
1019
|
+
}
|
|
1020
|
+
discardLateOrQuarantine(requestId) {
|
|
1021
|
+
this.pruneIdState();
|
|
1022
|
+
if (this.tombstones.has(requestId) || this.quarantinedRequestIds.has(requestId))
|
|
1023
|
+
return;
|
|
1024
|
+
if (this.quarantinedRequestIds.size >= this.quarantineLimit()) {
|
|
1025
|
+
this.terminate(new BackendClientError("backend_quarantine_limit", "Backend transport recycled because its unknown-requestId quarantine bound was reached.", true));
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
this.quarantinedRequestIds.set(requestId, Date.now() + this.lateOutputGraceMs());
|
|
1029
|
+
this.protocolQuarantined = true;
|
|
1030
|
+
this.scheduleQuarantineRecycle();
|
|
1031
|
+
}
|
|
1032
|
+
consumeTombstoneResponse(requestId) {
|
|
1033
|
+
const route = this.tombstones.get(requestId);
|
|
1034
|
+
if (route === undefined)
|
|
1035
|
+
return false;
|
|
1036
|
+
if (route.kind !== "unary") {
|
|
1037
|
+
this.terminate(new BackendClientError("unexpected_backend_response", `Backend sent a unary response for tombstoned stream requestId ${requestId}.`, true));
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
this.tombstones.delete(requestId);
|
|
1041
|
+
this.clearTombstoneRecycleTimer();
|
|
1042
|
+
this.scheduleTombstoneRecycleIfNeeded();
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
consumeTombstoneEvent(requestId, type) {
|
|
1046
|
+
const route = this.tombstones.get(requestId);
|
|
1047
|
+
if (route === undefined)
|
|
1048
|
+
return false;
|
|
1049
|
+
if (route.kind !== "stream") {
|
|
1050
|
+
this.terminate(new BackendClientError("unexpected_backend_event", `Backend sent a stream event for tombstoned unary requestId ${requestId}.`, true));
|
|
1051
|
+
return true;
|
|
1052
|
+
}
|
|
1053
|
+
if (type === "completed" || type === "error") {
|
|
1054
|
+
this.tombstones.delete(requestId);
|
|
1055
|
+
this.clearTombstoneRecycleTimer();
|
|
1056
|
+
this.scheduleTombstoneRecycleIfNeeded();
|
|
1057
|
+
}
|
|
1058
|
+
return true;
|
|
1059
|
+
}
|
|
1060
|
+
lateOutputGraceMs() {
|
|
1061
|
+
return this.options.lateOutputGraceMs ?? DEFAULT_BACKEND_LATE_OUTPUT_GRACE_MS;
|
|
1062
|
+
}
|
|
1063
|
+
tombstoneLimit() {
|
|
1064
|
+
return this.options.tombstoneLimit ?? DEFAULT_BACKEND_TOMBSTONE_LIMIT;
|
|
1065
|
+
}
|
|
1066
|
+
quarantineLimit() {
|
|
1067
|
+
return this.options.quarantineLimit ?? DEFAULT_BACKEND_QUARANTINE_LIMIT;
|
|
1068
|
+
}
|
|
1069
|
+
frameLimitBytes() {
|
|
1070
|
+
return this.options.frameLimitBytes ?? BACKEND_NDJSON_FRAME_LIMIT_BYTES;
|
|
1071
|
+
}
|
|
1072
|
+
maxInFlight() {
|
|
1073
|
+
return this.options.maxInFlight ?? DEFAULT_BACKEND_MAX_IN_FLIGHT;
|
|
1074
|
+
}
|
|
1075
|
+
assertCanIssue() {
|
|
1076
|
+
if (this.closed) {
|
|
1077
|
+
throw new BackendClientError("backend_closed", "Backend transport is closed.", true);
|
|
1078
|
+
}
|
|
1079
|
+
if (this.protocolQuarantined) {
|
|
1080
|
+
throw new BackendClientError("backend_protocol_quarantined", "Backend transport is quarantined after an unknown requestId; wait for it to recycle before sending new work.", true);
|
|
1081
|
+
}
|
|
1082
|
+
if (this.handshakeState === "blocked") {
|
|
1083
|
+
throw this.handshakeError ?? new BackendClientError("backend_hello_rejected", "Backend hello negotiation has blocked this backend transport.", false);
|
|
1084
|
+
}
|
|
1085
|
+
if (this.child === undefined) {
|
|
1086
|
+
throw new BackendClientError("backend_closed", "Backend process is not running.", true);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
scheduleTombstoneRecycle() {
|
|
1090
|
+
if (this.tombstoneRecycleTimer !== undefined)
|
|
1091
|
+
return;
|
|
1092
|
+
const nextExpiry = Math.min(...[...this.tombstones.values()].map(route => route.expiresAt));
|
|
1093
|
+
const delay = Math.max(1, nextExpiry - Date.now());
|
|
1094
|
+
this.tombstoneRecycleTimer = setTimeout(() => {
|
|
1095
|
+
this.tombstoneRecycleTimer = undefined;
|
|
1096
|
+
const expired = [...this.tombstones.values()].some(route => route.expiresAt <= Date.now());
|
|
1097
|
+
if (expired) {
|
|
1098
|
+
this.terminate(new BackendClientError("backend_late_output_timeout", "Backend transport recycled because a timed-out or cancelled route did not produce its terminal output within the bounded grace period.", true));
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
this.scheduleTombstoneRecycle();
|
|
1102
|
+
}, delay);
|
|
1103
|
+
this.tombstoneRecycleTimer.unref?.();
|
|
1104
|
+
}
|
|
1105
|
+
scheduleTombstoneRecycleIfNeeded() {
|
|
1106
|
+
if (this.tombstones.size === 0)
|
|
1107
|
+
return;
|
|
1108
|
+
this.scheduleTombstoneRecycle();
|
|
1109
|
+
}
|
|
1110
|
+
clearTombstoneRecycleTimer() {
|
|
1111
|
+
if (this.tombstoneRecycleTimer === undefined)
|
|
1112
|
+
return;
|
|
1113
|
+
clearTimeout(this.tombstoneRecycleTimer);
|
|
1114
|
+
this.tombstoneRecycleTimer = undefined;
|
|
1115
|
+
}
|
|
1116
|
+
scheduleQuarantineRecycle() {
|
|
1117
|
+
if (this.quarantineRecycleTimer === undefined) {
|
|
1118
|
+
this.quarantineRecycleTimer = setTimeout(() => {
|
|
1119
|
+
this.quarantineRecycleTimer = undefined;
|
|
1120
|
+
this.recycleQuarantinedTransport();
|
|
1121
|
+
}, this.lateOutputGraceMs());
|
|
1122
|
+
this.quarantineRecycleTimer.unref?.();
|
|
1123
|
+
}
|
|
1124
|
+
this.maybeRecycleQuarantined();
|
|
1125
|
+
}
|
|
1126
|
+
maybeRecycleQuarantined() {
|
|
1127
|
+
if (!this.protocolQuarantined || this.handshakePromise !== undefined)
|
|
1128
|
+
return;
|
|
1129
|
+
if (this.pendingResponses.size > 0
|
|
1130
|
+
|| this.pendingStreams.size > 0
|
|
1131
|
+
|| this.waitingRequests.size > 0
|
|
1132
|
+
|| this.waitingStreams.size > 0)
|
|
1133
|
+
return;
|
|
1134
|
+
this.recycleQuarantinedTransport();
|
|
1135
|
+
}
|
|
1136
|
+
recycleQuarantinedTransport() {
|
|
1137
|
+
if (!this.protocolQuarantined)
|
|
1138
|
+
return;
|
|
1139
|
+
this.protocolQuarantined = false;
|
|
1140
|
+
this.clearQuarantineRecycleTimer();
|
|
1141
|
+
const child = this.child;
|
|
1142
|
+
if (child !== undefined) {
|
|
1143
|
+
this.terminate(new BackendClientError("backend_protocol_quarantined", "Backend transport was recycled after an unknown requestId.", true), child);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
clearQuarantineRecycleTimer() {
|
|
1147
|
+
if (this.quarantineRecycleTimer === undefined)
|
|
1148
|
+
return;
|
|
1149
|
+
clearTimeout(this.quarantineRecycleTimer);
|
|
1150
|
+
this.quarantineRecycleTimer = undefined;
|
|
1151
|
+
}
|
|
1152
|
+
pruneIdState() {
|
|
1153
|
+
// Tombstones are safety routes, not cache entries. They are removed only
|
|
1154
|
+
// by the expected late terminal message or by process recycle.
|
|
1155
|
+
}
|
|
1156
|
+
handleProcessFailure(child, error) {
|
|
1157
|
+
if (this.child !== child)
|
|
1158
|
+
return;
|
|
1159
|
+
this.child = undefined;
|
|
1160
|
+
this.stdout?.destroy();
|
|
1161
|
+
this.stdout = undefined;
|
|
1162
|
+
this.handshakeState = "unknown";
|
|
1163
|
+
this.handshakeError = undefined;
|
|
1164
|
+
this.protocolQuarantined = false;
|
|
1165
|
+
this.clearQuarantineRecycleTimer();
|
|
1166
|
+
this.clearTombstoneRecycleTimer();
|
|
1167
|
+
this.tombstones.clear();
|
|
1168
|
+
this.quarantinedRequestIds.clear();
|
|
1169
|
+
this.retireWriteLifecycle(child);
|
|
1170
|
+
this.failAll(error);
|
|
1171
|
+
}
|
|
1172
|
+
terminate(error, expectedChild = this.child) {
|
|
1173
|
+
const child = expectedChild;
|
|
1174
|
+
if (child === undefined) {
|
|
1175
|
+
this.failAll(error);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (this.child === child) {
|
|
1179
|
+
this.child = undefined;
|
|
1180
|
+
this.stdout?.destroy();
|
|
1181
|
+
this.stdout = undefined;
|
|
1182
|
+
this.handshakeState = "unknown";
|
|
1183
|
+
this.handshakeError = undefined;
|
|
1184
|
+
this.retireWriteLifecycle(child);
|
|
1185
|
+
}
|
|
1186
|
+
this.protocolQuarantined = false;
|
|
1187
|
+
this.clearQuarantineRecycleTimer();
|
|
1188
|
+
this.clearTombstoneRecycleTimer();
|
|
1189
|
+
this.tombstones.clear();
|
|
1190
|
+
this.quarantinedRequestIds.clear();
|
|
1191
|
+
child.removeAllListeners("error");
|
|
1192
|
+
child.removeAllListeners("exit");
|
|
1193
|
+
child.kill();
|
|
1194
|
+
this.failAll(error);
|
|
1195
|
+
}
|
|
348
1196
|
}
|
|
349
1197
|
function unwrapResponse(response) {
|
|
350
1198
|
if (response.ok)
|
|
351
1199
|
return response.result;
|
|
352
1200
|
throw new BackendClientError(response.error.code, response.error.message, response.error.recoverable);
|
|
353
1201
|
}
|
|
354
|
-
function
|
|
355
|
-
const
|
|
1202
|
+
function attachOperationCompatibility(result, transport) {
|
|
1203
|
+
const report = transport.getCompatibilityReport?.();
|
|
1204
|
+
if (report === undefined)
|
|
1205
|
+
return result;
|
|
1206
|
+
try {
|
|
1207
|
+
return {
|
|
1208
|
+
...result,
|
|
1209
|
+
compatibility: validateBackendCompatibilityReport(report)
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
catch {
|
|
1213
|
+
return result;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function attachDoctorCompatibility(result, args, report) {
|
|
1217
|
+
if (report === undefined || (args?.check !== undefined && !args.check.includes("compatibility")))
|
|
1218
|
+
return result;
|
|
1219
|
+
if (result.data === undefined)
|
|
1220
|
+
return result;
|
|
1221
|
+
const check = compatibilityCheckFromReport(report);
|
|
1222
|
+
return {
|
|
1223
|
+
...result,
|
|
1224
|
+
data: {
|
|
1225
|
+
...result.data,
|
|
1226
|
+
checks: { ...result.data.checks, compatibility: check },
|
|
1227
|
+
ready: result.data.ready && check.status !== "blocked"
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
function compatibilityCheckFromReport(report) {
|
|
1232
|
+
const warning = report.warnings[0];
|
|
1233
|
+
return {
|
|
1234
|
+
status: report.status === "blocked" ? "blocked" : report.status === "warning" ? "unknown" : report.status === "compatible" ? "ok" : "unknown",
|
|
1235
|
+
message: warning?.message ?? "Backend protocol and advertised capabilities are compatible.",
|
|
1236
|
+
...(warning?.code === undefined ? {} : { code: warning.code }),
|
|
1237
|
+
details: report
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function parseOperationResult(value, validator) {
|
|
1241
|
+
try {
|
|
1242
|
+
return validator(value);
|
|
1243
|
+
}
|
|
1244
|
+
catch {
|
|
1245
|
+
// Result validation errors must not leak provider text, prompts, paths, or
|
|
1246
|
+
// opaque journal diagnostics through the backend facade.
|
|
1247
|
+
throw new BackendClientError("invalid_operation_result", "Backend returned an invalid transactional operation result.", true);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
function validateOperationSubmitRequest(value) {
|
|
1251
|
+
validateOperationRequestForClient(value, validateWireSubmitRequest);
|
|
1252
|
+
}
|
|
1253
|
+
function validateOperationCollectRequest(value) {
|
|
1254
|
+
validateOperationRequestForClient(value, validateWireCollectRequest);
|
|
1255
|
+
}
|
|
1256
|
+
function validateOperationInspectRequest(value) {
|
|
1257
|
+
validateOperationRequestForClient(value, validateWireInspectRequest);
|
|
1258
|
+
}
|
|
1259
|
+
function validateOperationControlRequest(value) {
|
|
1260
|
+
validateOperationRequestForClient(value, validateWireControlRequest);
|
|
1261
|
+
}
|
|
1262
|
+
function validateOperationRequestForClient(value, validator) {
|
|
1263
|
+
try {
|
|
1264
|
+
validator(value);
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
if (error instanceof OperationWireRequestError)
|
|
1268
|
+
throw invalidOperationRequest();
|
|
1269
|
+
throw error;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
function invalidOperationRequest() {
|
|
1273
|
+
return new BackendClientError("invalid_operation_request", "Transactional operation request is invalid.", false);
|
|
1274
|
+
}
|
|
1275
|
+
function streamFromBackendEvents(events, onReturn) {
|
|
1276
|
+
let returnRequested = false;
|
|
1277
|
+
let sourceReturned = false;
|
|
1278
|
+
const sourceIterator = events[Symbol.asyncIterator]();
|
|
1279
|
+
const returnSource = () => {
|
|
1280
|
+
if (sourceReturned)
|
|
1281
|
+
return;
|
|
1282
|
+
sourceReturned = true;
|
|
1283
|
+
try {
|
|
1284
|
+
const result = sourceIterator.return?.();
|
|
1285
|
+
if (result !== undefined)
|
|
1286
|
+
void Promise.resolve(result).catch(() => { });
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
// A source iterator's cleanup must not turn caller cancellation into a
|
|
1290
|
+
// second observable stream failure.
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
const cancel = () => {
|
|
1294
|
+
if (returnRequested)
|
|
1295
|
+
return;
|
|
1296
|
+
returnRequested = true;
|
|
1297
|
+
onReturn?.();
|
|
1298
|
+
returnSource();
|
|
1299
|
+
};
|
|
1300
|
+
const queue = new AsyncQueue(DEFAULT_BACKEND_STREAM_QUEUE_LIMIT, cancel, DEFAULT_BACKEND_STREAM_QUEUE_BYTES_LIMIT);
|
|
356
1301
|
let resolveCompleted;
|
|
357
1302
|
let rejectCompleted;
|
|
358
1303
|
const completed = new Promise((resolve, reject) => {
|
|
@@ -361,18 +1306,26 @@ function streamFromBackendEvents(events) {
|
|
|
361
1306
|
});
|
|
362
1307
|
void (async () => {
|
|
363
1308
|
try {
|
|
364
|
-
|
|
1309
|
+
while (true) {
|
|
1310
|
+
const next = await sourceIterator.next();
|
|
1311
|
+
if (next.done)
|
|
1312
|
+
break;
|
|
1313
|
+
const event = next.value;
|
|
365
1314
|
if (event.type === "run_item_stream_event") {
|
|
366
|
-
queue.push({
|
|
1315
|
+
if (!queue.push({
|
|
367
1316
|
type: "run_item_stream_event",
|
|
368
1317
|
name: event.name,
|
|
369
1318
|
item: event.item
|
|
370
|
-
})
|
|
1319
|
+
})) {
|
|
1320
|
+
cancel();
|
|
1321
|
+
throw new BackendClientError("backend_stream_overflow", "High-level backend stream buffering exceeded its bounded event queue.", true);
|
|
1322
|
+
}
|
|
371
1323
|
continue;
|
|
372
1324
|
}
|
|
373
1325
|
if (event.type === "completed") {
|
|
374
1326
|
resolveCompleted(event.result);
|
|
375
1327
|
queue.finish();
|
|
1328
|
+
returnSource();
|
|
376
1329
|
return;
|
|
377
1330
|
}
|
|
378
1331
|
if (event.type === "error") {
|
|
@@ -382,6 +1335,7 @@ function streamFromBackendEvents(events) {
|
|
|
382
1335
|
throw new BackendClientError("stream_incomplete", "Backend stream ended before a completed event.", true);
|
|
383
1336
|
}
|
|
384
1337
|
catch (error) {
|
|
1338
|
+
returnSource();
|
|
385
1339
|
queue.fail(error);
|
|
386
1340
|
rejectCompleted(error);
|
|
387
1341
|
}
|
|
@@ -392,41 +1346,319 @@ function streamFromBackendEvents(events) {
|
|
|
392
1346
|
};
|
|
393
1347
|
}
|
|
394
1348
|
function requireRequestId(request) {
|
|
395
|
-
if (request.requestId ===
|
|
1349
|
+
if (typeof request.requestId !== "string" || request.requestId.length === 0) {
|
|
396
1350
|
throw new BackendClientError("missing_request_id", "Backend transport requests require requestId.", false);
|
|
397
1351
|
}
|
|
1352
|
+
if (!isValidBackendRequestId(request.requestId)) {
|
|
1353
|
+
throw new BackendClientError("invalid_request_id", "Backend transport requestId is malformed or exceeds its bound.", false);
|
|
1354
|
+
}
|
|
398
1355
|
return request.requestId;
|
|
399
1356
|
}
|
|
1357
|
+
function parseBackendResponseMessage(value) {
|
|
1358
|
+
requireExactSchema(value, BACKEND_RESPONSE_SCHEMA_VERSION, "response");
|
|
1359
|
+
const requestId = requireMessageRequestId(value);
|
|
1360
|
+
const ok = value.ok;
|
|
1361
|
+
if (typeof ok !== "boolean") {
|
|
1362
|
+
throw new BackendClientError("invalid_backend_response", "Backend response ok must be a boolean.", true);
|
|
1363
|
+
}
|
|
1364
|
+
if (ok) {
|
|
1365
|
+
ensureAllowedKeys(value, ["schemaVersion", "requestId", "ok", "result"]);
|
|
1366
|
+
if (!Object.hasOwn(value, "result") || Object.hasOwn(value, "error")) {
|
|
1367
|
+
throw new BackendClientError("invalid_backend_response", `Backend response for requestId ${requestId} must contain exactly one result branch.`, true);
|
|
1368
|
+
}
|
|
1369
|
+
return value;
|
|
1370
|
+
}
|
|
1371
|
+
ensureAllowedKeys(value, ["schemaVersion", "requestId", "ok", "error"]);
|
|
1372
|
+
if (!isRecord(value.error)
|
|
1373
|
+
|| typeof value.error.code !== "string"
|
|
1374
|
+
|| value.error.code.length === 0
|
|
1375
|
+
|| typeof value.error.message !== "string"
|
|
1376
|
+
|| value.error.message.length === 0
|
|
1377
|
+
|| typeof value.error.recoverable !== "boolean"
|
|
1378
|
+
|| Object.hasOwn(value, "result")) {
|
|
1379
|
+
throw new BackendClientError("invalid_backend_response", `Backend error payload for requestId ${requestId} is malformed.`, true);
|
|
1380
|
+
}
|
|
1381
|
+
ensureAllowedKeys(value.error, ["code", "message", "recoverable"]);
|
|
1382
|
+
return value;
|
|
1383
|
+
}
|
|
1384
|
+
function parseBackendEventMessage(value) {
|
|
1385
|
+
requireExactSchema(value, BACKEND_EVENT_SCHEMA_VERSION, "event");
|
|
1386
|
+
const requestId = requireMessageRequestId(value);
|
|
1387
|
+
const type = value.type;
|
|
1388
|
+
if (typeof type !== "string") {
|
|
1389
|
+
throw new BackendClientError("invalid_backend_event", `Backend event for requestId ${requestId} is missing type.`, true);
|
|
1390
|
+
}
|
|
1391
|
+
switch (type) {
|
|
1392
|
+
case "run_item_stream_event":
|
|
1393
|
+
ensureAllowedKeys(value, ["schemaVersion", "requestId", "type", "name", "item"]);
|
|
1394
|
+
if (typeof value.name !== "string"
|
|
1395
|
+
|| value.name.length === 0
|
|
1396
|
+
|| !isRecord(value.item)) {
|
|
1397
|
+
throw new BackendClientError("invalid_backend_event", `Backend run-item event for requestId ${requestId} is malformed.`, true);
|
|
1398
|
+
}
|
|
1399
|
+
break;
|
|
1400
|
+
case "agent_updated_stream_event":
|
|
1401
|
+
ensureAllowedKeys(value, ["schemaVersion", "requestId", "type", "agent"]);
|
|
1402
|
+
if (!isRecord(value.agent)) {
|
|
1403
|
+
throw new BackendClientError("invalid_backend_event", `Backend agent-update event for requestId ${requestId} is malformed.`, true);
|
|
1404
|
+
}
|
|
1405
|
+
break;
|
|
1406
|
+
case "completed":
|
|
1407
|
+
ensureAllowedKeys(value, ["schemaVersion", "requestId", "type", "result"]);
|
|
1408
|
+
if (!Object.hasOwn(value, "result")) {
|
|
1409
|
+
throw new BackendClientError("invalid_backend_event", `Backend completed event for requestId ${requestId} is missing result.`, true);
|
|
1410
|
+
}
|
|
1411
|
+
break;
|
|
1412
|
+
case "error":
|
|
1413
|
+
ensureAllowedKeys(value, ["schemaVersion", "requestId", "type", "error"]);
|
|
1414
|
+
if (!isRecord(value.error)
|
|
1415
|
+
|| typeof value.error.code !== "string"
|
|
1416
|
+
|| value.error.code.length === 0
|
|
1417
|
+
|| typeof value.error.message !== "string"
|
|
1418
|
+
|| value.error.message.length === 0
|
|
1419
|
+
|| typeof value.error.recoverable !== "boolean") {
|
|
1420
|
+
throw new BackendClientError("invalid_backend_event", `Backend error event for requestId ${requestId} is malformed.`, true);
|
|
1421
|
+
}
|
|
1422
|
+
ensureAllowedKeys(value.error, ["code", "message", "recoverable"]);
|
|
1423
|
+
break;
|
|
1424
|
+
default:
|
|
1425
|
+
throw new BackendClientError("invalid_backend_event", `Backend event for requestId ${requestId} has unsupported type ${type}.`, true);
|
|
1426
|
+
}
|
|
1427
|
+
return value;
|
|
1428
|
+
}
|
|
1429
|
+
function requireExactSchema(value, schemaVersion, kind) {
|
|
1430
|
+
if (value.schemaVersion !== schemaVersion) {
|
|
1431
|
+
throw new BackendClientError("unsupported_backend_schema", `Backend emitted an unsupported ${kind} protocol schema.`, true);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
function requireMessageRequestId(value) {
|
|
1435
|
+
if (!isValidBackendRequestId(value.requestId)) {
|
|
1436
|
+
throw new BackendClientError("missing_backend_request_id", "Backend protocol message requires a bounded requestId.", true);
|
|
1437
|
+
}
|
|
1438
|
+
return value.requestId;
|
|
1439
|
+
}
|
|
1440
|
+
function ensureAllowedKeys(value, allowed) {
|
|
1441
|
+
const allowedSet = new Set(allowed);
|
|
1442
|
+
if (Object.keys(value).some(key => !allowedSet.has(key))) {
|
|
1443
|
+
throw new BackendClientError("invalid_backend_message", "Backend protocol message contains unsupported fields.", true);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
function isBoundedIdentityField(value) {
|
|
1447
|
+
return typeof value === "string"
|
|
1448
|
+
&& value.length > 0
|
|
1449
|
+
&& value.length <= MAX_BACKEND_IDENTITY_FIELD_LENGTH
|
|
1450
|
+
&& value.trim() === value
|
|
1451
|
+
&& !/[\u0000-\u001f\u007f]/u.test(value);
|
|
1452
|
+
}
|
|
1453
|
+
function asProtocolClientError(error) {
|
|
1454
|
+
if (error instanceof BackendClientError)
|
|
1455
|
+
return error;
|
|
1456
|
+
return new BackendClientError("invalid_backend_message", "Backend protocol message validation failed.", true);
|
|
1457
|
+
}
|
|
1458
|
+
function isCompatibleLegacyVersion(value) {
|
|
1459
|
+
return isRecord(value)
|
|
1460
|
+
&& value.protocolVersion === BACKEND_REQUEST_SCHEMA_VERSION
|
|
1461
|
+
&& typeof value.name === "string"
|
|
1462
|
+
&& value.name.length > 0
|
|
1463
|
+
&& typeof value.runtime === "string"
|
|
1464
|
+
&& value.runtime.length > 0;
|
|
1465
|
+
}
|
|
1466
|
+
function isCompatibleLegacyCapabilities(value) {
|
|
1467
|
+
if (!isRecord(value)
|
|
1468
|
+
|| value.protocolVersion !== BACKEND_REQUEST_SCHEMA_VERSION
|
|
1469
|
+
|| !Array.isArray(value.commands)
|
|
1470
|
+
|| value.commands.some(command => typeof command !== "string"))
|
|
1471
|
+
return false;
|
|
1472
|
+
const commands = value.commands;
|
|
1473
|
+
const requiredCommands = [
|
|
1474
|
+
"backend.version",
|
|
1475
|
+
"backend.health",
|
|
1476
|
+
"backend.capabilities",
|
|
1477
|
+
"runner.run",
|
|
1478
|
+
"runner.stream"
|
|
1479
|
+
];
|
|
1480
|
+
if (requiredCommands.some(command => !commands.includes(command)))
|
|
1481
|
+
return false;
|
|
1482
|
+
if (!Array.isArray(value.transports)
|
|
1483
|
+
|| value.transports.some(transport => transport !== "stdio" && transport !== "http")
|
|
1484
|
+
|| !value.transports.includes("stdio"))
|
|
1485
|
+
return false;
|
|
1486
|
+
return isRecord(value.streaming)
|
|
1487
|
+
&& Array.isArray(value.streaming.modes)
|
|
1488
|
+
&& value.streaming.modes.every(mode => mode === "ndjson" || mode === "sse")
|
|
1489
|
+
&& value.streaming.modes.includes("ndjson")
|
|
1490
|
+
&& value.streaming.tokenDeltas === false;
|
|
1491
|
+
}
|
|
1492
|
+
function isNegotiatedHello(value, requestPayload) {
|
|
1493
|
+
if (!isRecord(value) || value.accepted !== true || !isRecord(value.capabilities))
|
|
1494
|
+
return false;
|
|
1495
|
+
const identityFields = [
|
|
1496
|
+
"backendSessionId",
|
|
1497
|
+
"packageName",
|
|
1498
|
+
"packageVersion",
|
|
1499
|
+
"runtime",
|
|
1500
|
+
"runtimeVersion",
|
|
1501
|
+
"buildDigest",
|
|
1502
|
+
"protocolVersion"
|
|
1503
|
+
];
|
|
1504
|
+
if (identityFields.some(field => !isBoundedIdentityField(value[field])))
|
|
1505
|
+
return false;
|
|
1506
|
+
const capabilities = value.capabilities;
|
|
1507
|
+
if (capabilities.protocolVersion !== BACKEND_REQUEST_SCHEMA_VERSION)
|
|
1508
|
+
return false;
|
|
1509
|
+
if (value.protocolVersion !== capabilities.protocolVersion
|
|
1510
|
+
|| value.backendSessionId !== capabilities.backendSessionId
|
|
1511
|
+
|| value.packageName !== capabilities.packageName
|
|
1512
|
+
|| value.packageVersion !== capabilities.packageVersion
|
|
1513
|
+
|| value.runtime !== capabilities.runtime
|
|
1514
|
+
|| value.runtimeVersion !== capabilities.runtimeVersion
|
|
1515
|
+
|| value.buildDigest !== capabilities.buildDigest)
|
|
1516
|
+
return false;
|
|
1517
|
+
if (!Array.isArray(capabilities.supportedProtocolVersions)
|
|
1518
|
+
|| !capabilities.supportedProtocolVersions.includes(BACKEND_REQUEST_SCHEMA_VERSION))
|
|
1519
|
+
return false;
|
|
1520
|
+
if (["backendSessionId", "packageName", "packageVersion", "runtime", "runtimeVersion", "buildDigest"]
|
|
1521
|
+
.some(field => !isBoundedIdentityField(capabilities[field])))
|
|
1522
|
+
return false;
|
|
1523
|
+
const requestedCapabilities = requestPayload.capabilities;
|
|
1524
|
+
if (!isRecord(requestedCapabilities))
|
|
1525
|
+
return false;
|
|
1526
|
+
const supportedCommands = capabilities.commands;
|
|
1527
|
+
const requestedCommands = requestedCapabilities.commands;
|
|
1528
|
+
if (!Array.isArray(supportedCommands)
|
|
1529
|
+
|| supportedCommands.length === 0
|
|
1530
|
+
|| supportedCommands.some(command => typeof command !== "string")
|
|
1531
|
+
|| !Array.isArray(requestedCommands)
|
|
1532
|
+
|| requestedCommands.some(command => typeof command !== "string" || !supportedCommands.includes(command)))
|
|
1533
|
+
return false;
|
|
1534
|
+
if (!Array.isArray(capabilities.transports)
|
|
1535
|
+
|| capabilities.transports.some(transport => transport !== "stdio" && transport !== "http")
|
|
1536
|
+
|| !capabilities.transports.includes("stdio"))
|
|
1537
|
+
return false;
|
|
1538
|
+
if (!isRecord(capabilities.streaming)
|
|
1539
|
+
|| !Array.isArray(capabilities.streaming.modes)
|
|
1540
|
+
|| capabilities.streaming.modes.some(mode => mode !== "ndjson" && mode !== "sse")
|
|
1541
|
+
|| !capabilities.streaming.modes.includes("ndjson")
|
|
1542
|
+
|| capabilities.streaming.tokenDeltas !== false)
|
|
1543
|
+
return false;
|
|
1544
|
+
const requestIds = capabilities.requestIds;
|
|
1545
|
+
if (!isRecord(requestIds)
|
|
1546
|
+
|| requestIds.required !== true
|
|
1547
|
+
|| (requestIds.scope !== "connection" && requestIds.scope !== "process"))
|
|
1548
|
+
return false;
|
|
1549
|
+
const multiplexing = capabilities.multiplexing;
|
|
1550
|
+
if (!isRecord(multiplexing)
|
|
1551
|
+
|| typeof multiplexing.unary !== "boolean"
|
|
1552
|
+
|| typeof multiplexing.streams !== "boolean")
|
|
1553
|
+
return false;
|
|
1554
|
+
const cancellation = capabilities.cancellation;
|
|
1555
|
+
if (!isRecord(cancellation)
|
|
1556
|
+
|| typeof cancellation.supported !== "boolean"
|
|
1557
|
+
|| typeof cancellation.requests !== "boolean"
|
|
1558
|
+
|| typeof cancellation.streams !== "boolean")
|
|
1559
|
+
return false;
|
|
1560
|
+
const tabs = capabilities.tabs;
|
|
1561
|
+
if (!isRecord(tabs)
|
|
1562
|
+
|| typeof tabs.stableProviderIdentity !== "boolean"
|
|
1563
|
+
|| typeof tabs.stableBrowserIdentity !== "boolean"
|
|
1564
|
+
|| typeof tabs.stableTabIdentity !== "boolean"
|
|
1565
|
+
|| (tabs.coordinationScope !== "none" && tabs.coordinationScope !== "process" && tabs.coordinationScope !== "provider")
|
|
1566
|
+
|| typeof tabs.authoritativeClaim !== "boolean"
|
|
1567
|
+
|| typeof tabs.fencing !== "boolean"
|
|
1568
|
+
|| typeof tabs.concurrentTabs !== "boolean"
|
|
1569
|
+
|| !consistentDeprecatedTabAliases(tabs))
|
|
1570
|
+
return false;
|
|
1571
|
+
return true;
|
|
1572
|
+
}
|
|
1573
|
+
function negotiatedMultiplexing(value) {
|
|
1574
|
+
if (!isRecord(value) || !isRecord(value.capabilities) || !isRecord(value.capabilities.multiplexing))
|
|
1575
|
+
return false;
|
|
1576
|
+
return value.capabilities.multiplexing.unary === true && value.capabilities.multiplexing.streams === true;
|
|
1577
|
+
}
|
|
1578
|
+
function consistentDeprecatedTabAliases(tabs) {
|
|
1579
|
+
const expectedStableIdentity = tabs.stableProviderIdentity === true
|
|
1580
|
+
&& tabs.stableBrowserIdentity === true
|
|
1581
|
+
&& tabs.stableTabIdentity === true;
|
|
1582
|
+
const expectedCoordination = tabs.coordinationScope !== "none";
|
|
1583
|
+
const expectedConcurrent = tabs.concurrentTabs === true;
|
|
1584
|
+
return (tabs.stableIdentity === undefined || tabs.stableIdentity === expectedStableIdentity)
|
|
1585
|
+
&& (tabs.coordination === undefined || tabs.coordination === expectedCoordination)
|
|
1586
|
+
&& (tabs.concurrent === undefined || tabs.concurrent === expectedConcurrent);
|
|
1587
|
+
}
|
|
400
1588
|
class AsyncQueue {
|
|
1589
|
+
maxValues;
|
|
1590
|
+
onReturn;
|
|
1591
|
+
maxBytes;
|
|
1592
|
+
sizeOf;
|
|
401
1593
|
values = [];
|
|
402
1594
|
waiters = [];
|
|
1595
|
+
queuedBytes = 0;
|
|
403
1596
|
done = false;
|
|
404
1597
|
error;
|
|
1598
|
+
returnCalled = false;
|
|
1599
|
+
constructor(maxValues = Number.POSITIVE_INFINITY, onReturn, maxBytes = Number.POSITIVE_INFINITY, sizeOf = boundedValueBytes) {
|
|
1600
|
+
this.maxValues = maxValues;
|
|
1601
|
+
this.onReturn = onReturn;
|
|
1602
|
+
this.maxBytes = maxBytes;
|
|
1603
|
+
this.sizeOf = sizeOf;
|
|
1604
|
+
}
|
|
405
1605
|
push(value) {
|
|
406
1606
|
if (this.done || this.error !== undefined)
|
|
407
|
-
return;
|
|
1607
|
+
return false;
|
|
1608
|
+
if (this.values.length >= this.maxValues)
|
|
1609
|
+
return false;
|
|
1610
|
+
const valueBytes = this.sizeOf(value);
|
|
1611
|
+
if (!Number.isFinite(valueBytes) || valueBytes < 0 || this.queuedBytes > this.maxBytes - valueBytes)
|
|
1612
|
+
return false;
|
|
408
1613
|
this.values.push(value);
|
|
1614
|
+
this.queuedBytes += valueBytes;
|
|
409
1615
|
this.wake();
|
|
1616
|
+
return true;
|
|
410
1617
|
}
|
|
411
1618
|
finish() {
|
|
1619
|
+
if (this.done || this.error !== undefined)
|
|
1620
|
+
return;
|
|
412
1621
|
this.done = true;
|
|
413
1622
|
this.wake();
|
|
414
1623
|
}
|
|
415
1624
|
fail(error) {
|
|
1625
|
+
if (this.done || this.error !== undefined)
|
|
1626
|
+
return;
|
|
1627
|
+
this.values.length = 0;
|
|
1628
|
+
this.queuedBytes = 0;
|
|
416
1629
|
this.error = error;
|
|
417
1630
|
this.wake();
|
|
418
1631
|
}
|
|
419
|
-
|
|
1632
|
+
[Symbol.asyncIterator]() {
|
|
1633
|
+
return {
|
|
1634
|
+
next: () => this.nextValue(),
|
|
1635
|
+
return: async () => {
|
|
1636
|
+
if (!this.returnCalled) {
|
|
1637
|
+
this.returnCalled = true;
|
|
1638
|
+
this.onReturn?.();
|
|
1639
|
+
}
|
|
1640
|
+
this.values.length = 0;
|
|
1641
|
+
this.queuedBytes = 0;
|
|
1642
|
+
this.done = true;
|
|
1643
|
+
this.wake();
|
|
1644
|
+
return { done: true, value: undefined };
|
|
1645
|
+
},
|
|
1646
|
+
[Symbol.asyncIterator]() {
|
|
1647
|
+
return this;
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
async nextValue() {
|
|
420
1652
|
while (true) {
|
|
421
1653
|
const value = this.values.shift();
|
|
422
1654
|
if (value !== undefined) {
|
|
423
|
-
|
|
424
|
-
|
|
1655
|
+
this.queuedBytes = Math.max(0, this.queuedBytes - this.sizeOf(value));
|
|
1656
|
+
return { done: false, value };
|
|
425
1657
|
}
|
|
426
1658
|
if (this.error !== undefined)
|
|
427
1659
|
throw this.error;
|
|
428
1660
|
if (this.done)
|
|
429
|
-
return;
|
|
1661
|
+
return { done: true, value: undefined };
|
|
430
1662
|
await new Promise(resolve => {
|
|
431
1663
|
this.waiters.push(resolve);
|
|
432
1664
|
});
|
|
@@ -438,6 +1670,113 @@ class AsyncQueue {
|
|
|
438
1670
|
waiter();
|
|
439
1671
|
}
|
|
440
1672
|
}
|
|
1673
|
+
function boundedValueBytes(value) {
|
|
1674
|
+
try {
|
|
1675
|
+
const encoded = JSON.stringify(value);
|
|
1676
|
+
return encoded === undefined ? Number.POSITIVE_INFINITY : Buffer.byteLength(encoded, "utf8");
|
|
1677
|
+
}
|
|
1678
|
+
catch {
|
|
1679
|
+
return Number.POSITIVE_INFINITY;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
441
1682
|
function isRecord(value) {
|
|
442
1683
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
443
1684
|
}
|
|
1685
|
+
function isSingleFlightState(state) {
|
|
1686
|
+
return state === "single-flight" || state === "legacy";
|
|
1687
|
+
}
|
|
1688
|
+
function isDefinitelyUnsentWriteError(error) {
|
|
1689
|
+
return error instanceof BackendClientError
|
|
1690
|
+
&& (error.code === "backend_frame_too_large"
|
|
1691
|
+
|| error.code === "invalid_backend_request"
|
|
1692
|
+
|| error.code === "backend_protocol_quarantined"
|
|
1693
|
+
|| error.code === "backend_closed"
|
|
1694
|
+
|| error.code === "backend_write_queue_overflow"
|
|
1695
|
+
|| error.code === "backend_request_cancelled");
|
|
1696
|
+
}
|
|
1697
|
+
class BackendFrameError extends Error {
|
|
1698
|
+
code;
|
|
1699
|
+
constructor(code, message) {
|
|
1700
|
+
super(message);
|
|
1701
|
+
this.code = code;
|
|
1702
|
+
this.name = "BackendFrameError";
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
async function* readBoundedNdjsonLines(input, limitBytes) {
|
|
1706
|
+
let buffered = Buffer.alloc(0);
|
|
1707
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1708
|
+
for await (const chunk of input) {
|
|
1709
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
|
|
1710
|
+
buffered = buffered.length === 0 ? Buffer.from(bytes) : Buffer.concat([buffered, bytes]);
|
|
1711
|
+
let newlineIndex = buffered.indexOf(0x0a);
|
|
1712
|
+
while (newlineIndex >= 0) {
|
|
1713
|
+
const frame = buffered.subarray(0, newlineIndex);
|
|
1714
|
+
buffered = buffered.subarray(newlineIndex + 1);
|
|
1715
|
+
if (frame.length > limitBytes) {
|
|
1716
|
+
throw new BackendFrameError("backend_frame_too_large", `Backend frame exceeds the ${limitBytes} byte limit.`);
|
|
1717
|
+
}
|
|
1718
|
+
const body = frame.length > 0 && frame[frame.length - 1] === 0x0d
|
|
1719
|
+
? frame.subarray(0, frame.length - 1)
|
|
1720
|
+
: frame;
|
|
1721
|
+
try {
|
|
1722
|
+
yield decoder.decode(body);
|
|
1723
|
+
}
|
|
1724
|
+
catch {
|
|
1725
|
+
throw new BackendFrameError("backend_invalid_encoding", "Backend stdout contained invalid UTF-8.");
|
|
1726
|
+
}
|
|
1727
|
+
newlineIndex = buffered.indexOf(0x0a);
|
|
1728
|
+
}
|
|
1729
|
+
if (buffered.length > limitBytes) {
|
|
1730
|
+
throw new BackendFrameError("backend_frame_too_large", `Backend frame exceeds the ${limitBytes} byte limit.`);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
if (buffered.length > 0) {
|
|
1734
|
+
throw new BackendFrameError("backend_unterminated_frame", "Backend stdout ended with an unterminated NDJSON frame.");
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
function validateTransportOptions(options) {
|
|
1738
|
+
if (!Array.isArray(options.command)
|
|
1739
|
+
|| options.command.length === 0
|
|
1740
|
+
|| options.command.some(part => typeof part !== "string")
|
|
1741
|
+
|| options.command[0]?.trim().length === 0) {
|
|
1742
|
+
throw new BackendClientError("invalid_backend_options", "Stdio backend command must contain a non-empty executable.", false);
|
|
1743
|
+
}
|
|
1744
|
+
for (const [name, value] of [
|
|
1745
|
+
["timeoutMs", options.timeoutMs],
|
|
1746
|
+
["handshakeTimeoutMs", options.handshakeTimeoutMs],
|
|
1747
|
+
["maxInFlight", options.maxInFlight],
|
|
1748
|
+
["streamQueueLimit", options.streamQueueLimit],
|
|
1749
|
+
["streamQueueBytesLimit", options.streamQueueBytesLimit],
|
|
1750
|
+
["writeQueueLimit", options.writeQueueLimit],
|
|
1751
|
+
["writeQueueBytesLimit", options.writeQueueBytesLimit],
|
|
1752
|
+
["lateOutputGraceMs", options.lateOutputGraceMs],
|
|
1753
|
+
["tombstoneLimit", options.tombstoneLimit],
|
|
1754
|
+
["quarantineLimit", options.quarantineLimit],
|
|
1755
|
+
["frameLimitBytes", options.frameLimitBytes]
|
|
1756
|
+
]) {
|
|
1757
|
+
const max = name === "timeoutMs" || name === "handshakeTimeoutMs" || name === "lateOutputGraceMs"
|
|
1758
|
+
? MAX_BACKEND_TIMER_MS
|
|
1759
|
+
: name === "frameLimitBytes" ? BACKEND_NDJSON_FRAME_LIMIT_BYTES
|
|
1760
|
+
: name === "streamQueueBytesLimit" ? MAX_BACKEND_STREAM_QUEUE_BYTES_LIMIT
|
|
1761
|
+
: name === "writeQueueBytesLimit" ? MAX_BACKEND_WRITE_QUEUE_BYTES_LIMIT
|
|
1762
|
+
: MAX_BACKEND_BUFFER_LIMIT;
|
|
1763
|
+
const minimum = name === "maxInFlight" ? MIN_BACKEND_IN_FLIGHT_LIMIT : 1;
|
|
1764
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum || value > max)) {
|
|
1765
|
+
throw new BackendClientError("invalid_backend_options", `Stdio backend option ${name} must be a safe integer at least ${minimum}.`, false);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (options.expectedIdentity !== undefined) {
|
|
1769
|
+
if (options.expectedIdentity === null || typeof options.expectedIdentity !== "object" || Array.isArray(options.expectedIdentity)) {
|
|
1770
|
+
throw new BackendClientError("invalid_backend_options", "Stdio backend expectedIdentity must be an object.", false);
|
|
1771
|
+
}
|
|
1772
|
+
const allowed = new Set(["backendSessionId", "packageName", "packageVersion", "runtime", "runtimeVersion", "buildDigest"]);
|
|
1773
|
+
if (Object.keys(options.expectedIdentity).some(key => !allowed.has(key))) {
|
|
1774
|
+
throw new BackendClientError("invalid_backend_options", "Stdio backend expectedIdentity contains unsupported fields.", false);
|
|
1775
|
+
}
|
|
1776
|
+
for (const value of Object.values(options.expectedIdentity)) {
|
|
1777
|
+
if (!isBoundedIdentityField(value)) {
|
|
1778
|
+
throw new BackendClientError("invalid_backend_options", "Stdio backend expectedIdentity fields must be bounded strings.", false);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|