@devicerail/client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +79 -0
- package/dist/errors.d.ts +76 -0
- package/dist/errors.js +132 -0
- package/dist/event-stream.d.ts +66 -0
- package/dist/event-stream.js +1153 -0
- package/dist/generated/response-schemas.d.ts +6 -0
- package/dist/generated/response-schemas.js +7621 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/ndjson.d.ts +13 -0
- package/dist/ndjson.js +134 -0
- package/dist/rpc-client.d.ts +69 -0
- package/dist/rpc-client.js +928 -0
- package/dist/rpc-response-validator.d.ts +5 -0
- package/dist/rpc-response-validator.js +190 -0
- package/dist/stderr-tail.d.ts +10 -0
- package/dist/stderr-tail.js +42 -0
- package/dist/write-queue.d.ts +26 -0
- package/dist/write-queue.js +273 -0
- package/package.json +56 -0
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { FeatureNotNegotiatedError, EventStreamAbortedError, HandshakeStateError, PendingRequestLimitError, ProtocolViolationError, RequestAbortedError, RpcRemoteError, TransportClosedError, WriteFrameTooLargeError, WriteQueueOverflowError, } from "./errors.js";
|
|
4
|
+
import { DEFAULT_MAX_FRAME_BYTES, NdjsonDecoder } from "./ndjson.js";
|
|
5
|
+
import { BoundedStderrTail, DEFAULT_STDERR_TAIL_BYTES } from "./stderr-tail.js";
|
|
6
|
+
import { NdjsonWriteQueue } from "./write-queue.js";
|
|
7
|
+
import { assertPureJsonValue, validateRpcResponse } from "./rpc-response-validator.js";
|
|
8
|
+
import { DeviceRailEventStream, connectEventStream, } from "./event-stream.js";
|
|
9
|
+
const REQUEST_CONTROL_FEATURE = "request.control.v1";
|
|
10
|
+
const ROUTING_FEATURE = "device.routing.v1";
|
|
11
|
+
const EVENTS_FEATURE = "events.snapshot.v1";
|
|
12
|
+
const ACTION_PROTECTED_FEATURE = "action.protected.v1";
|
|
13
|
+
const EVENTS_STREAM_FEATURE = "events.stream.v1";
|
|
14
|
+
const MEDIA_STREAM_FEATURE = "media.stream.v1";
|
|
15
|
+
const SESSION_EXPORT_PAGE_FEATURE = "session.export.page.v1";
|
|
16
|
+
const UI_SNAPSHOT_FEATURE = "observation.uiSnapshot.v1";
|
|
17
|
+
const SEMANTIC_ACTIONS_FEATURE = "device.semanticActions.v1";
|
|
18
|
+
const VERDICT_RECORD_FEATURE = "verdict.record.v1";
|
|
19
|
+
const SUPPORTED_PROTOCOL_MAJOR = 1;
|
|
20
|
+
const SUPPORTED_PROTOCOL_MAX_MINOR = 5;
|
|
21
|
+
const semanticActionNames = new Set([
|
|
22
|
+
"findElement",
|
|
23
|
+
"tapElement",
|
|
24
|
+
"clearElement",
|
|
25
|
+
"setElementValue",
|
|
26
|
+
"waitForElement",
|
|
27
|
+
]);
|
|
28
|
+
const timeoutMethods = new Set([
|
|
29
|
+
"device.capabilities",
|
|
30
|
+
"device.connect",
|
|
31
|
+
"device.disconnect",
|
|
32
|
+
"device.execute",
|
|
33
|
+
"device.observe",
|
|
34
|
+
"media.stream.capture",
|
|
35
|
+
]);
|
|
36
|
+
const requiredFeatures = {
|
|
37
|
+
"device.select": ROUTING_FEATURE,
|
|
38
|
+
"devices.list": ROUTING_FEATURE,
|
|
39
|
+
"events.clear": EVENTS_FEATURE,
|
|
40
|
+
"events.list": EVENTS_FEATURE,
|
|
41
|
+
"events.stream.open": EVENTS_STREAM_FEATURE,
|
|
42
|
+
"media.stream.capture": MEDIA_STREAM_FEATURE,
|
|
43
|
+
"media.stream.end": MEDIA_STREAM_FEATURE,
|
|
44
|
+
"media.stream.start": MEDIA_STREAM_FEATURE,
|
|
45
|
+
"request.cancel": REQUEST_CONTROL_FEATURE,
|
|
46
|
+
"session.export": EVENTS_FEATURE,
|
|
47
|
+
"sessions.list": EVENTS_FEATURE,
|
|
48
|
+
"ui.snapshot.get": UI_SNAPSHOT_FEATURE,
|
|
49
|
+
"verdict.record": VERDICT_RECORD_FEATURE,
|
|
50
|
+
};
|
|
51
|
+
function positiveSafeInteger(value, name) {
|
|
52
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
53
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function rpcIdKey(id) {
|
|
58
|
+
return `${typeof id}:${String(id)}`;
|
|
59
|
+
}
|
|
60
|
+
function isObject(value) {
|
|
61
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
function assertExactKeys(value, required, optional, location) {
|
|
64
|
+
const allowed = new Set([...required, ...optional]);
|
|
65
|
+
for (const key of required) {
|
|
66
|
+
if (!Object.hasOwn(value, key)) {
|
|
67
|
+
throw new ProtocolViolationError(`${location} is missing ${key}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const key of Object.keys(value)) {
|
|
71
|
+
if (!allowed.has(key)) {
|
|
72
|
+
throw new ProtocolViolationError(`${location} contains unknown field ${key}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function parseRpcId(value, location = "response id") {
|
|
77
|
+
if (typeof value === "string") {
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
throw new ProtocolViolationError(`${location} must be a string or non-negative safe integer`);
|
|
84
|
+
}
|
|
85
|
+
function parseRpcError(value) {
|
|
86
|
+
if (!isObject(value)) {
|
|
87
|
+
throw new ProtocolViolationError("response error must be an object");
|
|
88
|
+
}
|
|
89
|
+
assertExactKeys(value, ["code", "data", "message"], [], "response error");
|
|
90
|
+
const { code, data, message } = value;
|
|
91
|
+
if (typeof code !== "number" ||
|
|
92
|
+
!Number.isInteger(code) ||
|
|
93
|
+
code < -2_147_483_648 ||
|
|
94
|
+
code > 2_147_483_647) {
|
|
95
|
+
throw new ProtocolViolationError("response error.code must be a signed 32-bit integer");
|
|
96
|
+
}
|
|
97
|
+
if (typeof message !== "string" || !isObject(data)) {
|
|
98
|
+
throw new ProtocolViolationError("response error message/data are invalid");
|
|
99
|
+
}
|
|
100
|
+
assertExactKeys(data, ["code", "message", "retryable"], ["details"], "response error.data");
|
|
101
|
+
if (typeof data.code !== "string" ||
|
|
102
|
+
typeof data.message !== "string" ||
|
|
103
|
+
typeof data.retryable !== "boolean") {
|
|
104
|
+
throw new ProtocolViolationError("response error.data is invalid");
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
function parseResponse(line) {
|
|
109
|
+
let value;
|
|
110
|
+
try {
|
|
111
|
+
value = JSON.parse(line);
|
|
112
|
+
}
|
|
113
|
+
catch (cause) {
|
|
114
|
+
throw new ProtocolViolationError("response frame is not valid JSON", { cause });
|
|
115
|
+
}
|
|
116
|
+
if (!isObject(value) || value.jsonrpc !== "2.0") {
|
|
117
|
+
throw new ProtocolViolationError('response must be a JSON-RPC 2.0 object');
|
|
118
|
+
}
|
|
119
|
+
const id = parseRpcId(value.id);
|
|
120
|
+
const hasResult = Object.hasOwn(value, "result");
|
|
121
|
+
const hasError = Object.hasOwn(value, "error");
|
|
122
|
+
if (hasResult === hasError) {
|
|
123
|
+
throw new ProtocolViolationError("response must contain exactly one of result or error");
|
|
124
|
+
}
|
|
125
|
+
if (hasError) {
|
|
126
|
+
assertExactKeys(value, ["error", "id", "jsonrpc"], [], "response");
|
|
127
|
+
return { envelope: value, error: parseRpcError(value.error), id, kind: "error" };
|
|
128
|
+
}
|
|
129
|
+
assertExactKeys(value, ["id", "jsonrpc", "result"], [], "response");
|
|
130
|
+
return { envelope: value, id, kind: "result", result: value.result };
|
|
131
|
+
}
|
|
132
|
+
function serializeRequest(request) {
|
|
133
|
+
try {
|
|
134
|
+
const serialized = JSON.stringify(request, (_key, value) => {
|
|
135
|
+
if (typeof value === "number" &&
|
|
136
|
+
(!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value)))) {
|
|
137
|
+
throw new ProtocolViolationError("request contains an unsafe JSON number");
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
});
|
|
141
|
+
if (serialized === undefined) {
|
|
142
|
+
throw new ProtocolViolationError("request is not JSON serializable");
|
|
143
|
+
}
|
|
144
|
+
assertPureJsonValue(JSON.parse(serialized));
|
|
145
|
+
return serialized;
|
|
146
|
+
}
|
|
147
|
+
catch (cause) {
|
|
148
|
+
if (cause instanceof ProtocolViolationError) {
|
|
149
|
+
throw cause;
|
|
150
|
+
}
|
|
151
|
+
throw new ProtocolViolationError("request is not JSON serializable", { cause });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function stringSet(value, location) {
|
|
155
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
156
|
+
throw new ProtocolViolationError(`${location} must be an array of strings`);
|
|
157
|
+
}
|
|
158
|
+
const result = new Set(value);
|
|
159
|
+
if (result.size !== value.length) {
|
|
160
|
+
throw new ProtocolViolationError(`${location} must contain unique values`);
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
function uint16(value, location) {
|
|
165
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 65_535) {
|
|
166
|
+
throw new ProtocolViolationError(`${location} must be an unsigned 16-bit integer`);
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
function snapshotHelloOffer(params) {
|
|
171
|
+
if (!isObject(params)) {
|
|
172
|
+
throw new ProtocolViolationError("system.hello params must be an object");
|
|
173
|
+
}
|
|
174
|
+
assertExactKeys(params, ["client", "protocol"], ["features"], "system.hello params");
|
|
175
|
+
if (!isObject(params.client)) {
|
|
176
|
+
throw new ProtocolViolationError("system.hello params.client must be an object");
|
|
177
|
+
}
|
|
178
|
+
assertExactKeys(params.client, ["name", "version"], [], "system.hello params.client");
|
|
179
|
+
if (typeof params.client.name !== "string" || typeof params.client.version !== "string") {
|
|
180
|
+
throw new ProtocolViolationError("system.hello client name/version must be strings");
|
|
181
|
+
}
|
|
182
|
+
if (!isObject(params.protocol)) {
|
|
183
|
+
throw new ProtocolViolationError("system.hello params.protocol must be an object");
|
|
184
|
+
}
|
|
185
|
+
assertExactKeys(params.protocol, ["ranges"], [], "system.hello params.protocol");
|
|
186
|
+
if (!Array.isArray(params.protocol.ranges) || params.protocol.ranges.length === 0) {
|
|
187
|
+
throw new ProtocolViolationError("system.hello protocol.ranges must be a non-empty array");
|
|
188
|
+
}
|
|
189
|
+
const ranges = params.protocol.ranges.map((candidate, index) => {
|
|
190
|
+
if (!isObject(candidate)) {
|
|
191
|
+
throw new ProtocolViolationError(`system.hello protocol.ranges[${index}] must be an object`);
|
|
192
|
+
}
|
|
193
|
+
assertExactKeys(candidate, ["major", "maxMinor", "minMinor"], [], `system.hello protocol.ranges[${index}]`);
|
|
194
|
+
const major = uint16(candidate.major, `system.hello protocol.ranges[${index}].major`);
|
|
195
|
+
const maxMinor = uint16(candidate.maxMinor, `system.hello protocol.ranges[${index}].maxMinor`);
|
|
196
|
+
const minMinor = uint16(candidate.minMinor, `system.hello protocol.ranges[${index}].minMinor`);
|
|
197
|
+
if (minMinor > maxMinor) {
|
|
198
|
+
throw new ProtocolViolationError(`system.hello protocol.ranges[${index}] has minMinor greater than maxMinor`);
|
|
199
|
+
}
|
|
200
|
+
if (major !== SUPPORTED_PROTOCOL_MAJOR || maxMinor > SUPPORTED_PROTOCOL_MAX_MINOR) {
|
|
201
|
+
throw new ProtocolViolationError(`system.hello protocol.ranges[${index}] exceeds client support for protocol 1.0-1.5`);
|
|
202
|
+
}
|
|
203
|
+
return { major, maxMinor, minMinor };
|
|
204
|
+
});
|
|
205
|
+
let requiredFeatures = new Set();
|
|
206
|
+
let optionalFeatures = new Set();
|
|
207
|
+
if (params.features !== undefined) {
|
|
208
|
+
if (!isObject(params.features)) {
|
|
209
|
+
throw new ProtocolViolationError("system.hello params.features must be an object");
|
|
210
|
+
}
|
|
211
|
+
assertExactKeys(params.features, [], ["optional", "required"], "system.hello params.features");
|
|
212
|
+
requiredFeatures =
|
|
213
|
+
params.features.required === undefined
|
|
214
|
+
? new Set()
|
|
215
|
+
: stringSet(params.features.required, "system.hello params.features.required");
|
|
216
|
+
optionalFeatures =
|
|
217
|
+
params.features.optional === undefined
|
|
218
|
+
? new Set()
|
|
219
|
+
: stringSet(params.features.optional, "system.hello params.features.optional");
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
client: { name: params.client.name, version: params.client.version },
|
|
223
|
+
offeredFeatures: new Set([...requiredFeatures, ...optionalFeatures]),
|
|
224
|
+
ranges,
|
|
225
|
+
requiredFeatures,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function validateHelloResult(value, offer) {
|
|
229
|
+
if (!isObject(value)) {
|
|
230
|
+
throw new ProtocolViolationError("system.hello result must be an object");
|
|
231
|
+
}
|
|
232
|
+
assertExactKeys(value, ["connectionId", "features", "protocol", "server", "transport"], [], "system.hello result");
|
|
233
|
+
if (typeof value.connectionId !== "string" ||
|
|
234
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value.connectionId)) {
|
|
235
|
+
throw new ProtocolViolationError("system.hello result.connectionId must be a UUID");
|
|
236
|
+
}
|
|
237
|
+
if (!isObject(value.features)) {
|
|
238
|
+
throw new ProtocolViolationError("system.hello result.features must be an object");
|
|
239
|
+
}
|
|
240
|
+
assertExactKeys(value.features, ["enabled"], [], "system.hello result.features");
|
|
241
|
+
const enabled = stringSet(value.features.enabled, "system.hello result.features.enabled");
|
|
242
|
+
for (const feature of offer.requiredFeatures) {
|
|
243
|
+
if (!enabled.has(feature)) {
|
|
244
|
+
throw new ProtocolViolationError(`system.hello omitted required feature ${feature}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
for (const feature of enabled) {
|
|
248
|
+
if (!offer.offeredFeatures.has(feature)) {
|
|
249
|
+
throw new ProtocolViolationError(`system.hello enabled unoffered feature ${feature}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!isObject(value.protocol)) {
|
|
253
|
+
throw new ProtocolViolationError("system.hello result.protocol must be an object");
|
|
254
|
+
}
|
|
255
|
+
assertExactKeys(value.protocol, ["selected"], [], "system.hello result.protocol");
|
|
256
|
+
if (!isObject(value.protocol.selected)) {
|
|
257
|
+
throw new ProtocolViolationError("system.hello result.protocol.selected must be an object");
|
|
258
|
+
}
|
|
259
|
+
assertExactKeys(value.protocol.selected, ["major", "minor"], [], "system.hello result.protocol.selected");
|
|
260
|
+
const selectedMajor = uint16(value.protocol.selected.major, "system.hello result.protocol.selected.major");
|
|
261
|
+
const selectedMinor = uint16(value.protocol.selected.minor, "system.hello result.protocol.selected.minor");
|
|
262
|
+
if (!offer.ranges.some((range) => range.major === selectedMajor &&
|
|
263
|
+
range.minMinor <= selectedMinor &&
|
|
264
|
+
selectedMinor <= range.maxMinor)) {
|
|
265
|
+
throw new ProtocolViolationError("system.hello selected a protocol outside the client offer");
|
|
266
|
+
}
|
|
267
|
+
if (selectedMajor !== SUPPORTED_PROTOCOL_MAJOR ||
|
|
268
|
+
selectedMinor > SUPPORTED_PROTOCOL_MAX_MINOR) {
|
|
269
|
+
throw new ProtocolViolationError("system.hello selected a protocol unsupported by this client");
|
|
270
|
+
}
|
|
271
|
+
if (enabled.has(REQUEST_CONTROL_FEATURE) && selectedMinor < 1) {
|
|
272
|
+
throw new ProtocolViolationError(`system.hello enabled ${REQUEST_CONTROL_FEATURE} before protocol 1.1`);
|
|
273
|
+
}
|
|
274
|
+
if (enabled.has(ROUTING_FEATURE) && selectedMinor < 2) {
|
|
275
|
+
throw new ProtocolViolationError(`system.hello enabled ${ROUTING_FEATURE} before protocol 1.2`);
|
|
276
|
+
}
|
|
277
|
+
if (enabled.has(ACTION_PROTECTED_FEATURE) && selectedMinor < 2) {
|
|
278
|
+
throw new ProtocolViolationError(`system.hello enabled ${ACTION_PROTECTED_FEATURE} before protocol 1.2`);
|
|
279
|
+
}
|
|
280
|
+
if (enabled.has(EVENTS_STREAM_FEATURE) && selectedMinor < 3) {
|
|
281
|
+
throw new ProtocolViolationError(`system.hello enabled ${EVENTS_STREAM_FEATURE} before protocol 1.3`);
|
|
282
|
+
}
|
|
283
|
+
if (enabled.has(MEDIA_STREAM_FEATURE) && selectedMinor < 4) {
|
|
284
|
+
throw new ProtocolViolationError(`system.hello enabled ${MEDIA_STREAM_FEATURE} before protocol 1.4`);
|
|
285
|
+
}
|
|
286
|
+
if (enabled.has(SESSION_EXPORT_PAGE_FEATURE) && selectedMinor < 4) {
|
|
287
|
+
throw new ProtocolViolationError(`system.hello enabled ${SESSION_EXPORT_PAGE_FEATURE} before protocol 1.4`);
|
|
288
|
+
}
|
|
289
|
+
for (const featureName of [
|
|
290
|
+
UI_SNAPSHOT_FEATURE,
|
|
291
|
+
SEMANTIC_ACTIONS_FEATURE,
|
|
292
|
+
VERDICT_RECORD_FEATURE,
|
|
293
|
+
]) {
|
|
294
|
+
if (enabled.has(featureName) && selectedMinor < 5) {
|
|
295
|
+
throw new ProtocolViolationError(`system.hello enabled ${featureName} before protocol 1.5`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (!isObject(value.server)) {
|
|
299
|
+
throw new ProtocolViolationError("system.hello result.server must be an object");
|
|
300
|
+
}
|
|
301
|
+
assertExactKeys(value.server, ["name", "version"], [], "system.hello result.server");
|
|
302
|
+
if (typeof value.server.name !== "string" || typeof value.server.version !== "string") {
|
|
303
|
+
throw new ProtocolViolationError("system.hello server name/version must be strings");
|
|
304
|
+
}
|
|
305
|
+
if (!isObject(value.transport)) {
|
|
306
|
+
throw new ProtocolViolationError("system.hello result.transport must be an object");
|
|
307
|
+
}
|
|
308
|
+
assertExactKeys(value.transport, ["framing", "kind"], [], "system.hello result.transport");
|
|
309
|
+
if (value.transport.kind !== "stdio" || value.transport.framing !== "ndjson") {
|
|
310
|
+
throw new ProtocolViolationError("system.hello negotiated a non-stdio/ndjson transport");
|
|
311
|
+
}
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
function processTransport(child) {
|
|
315
|
+
let spawnError;
|
|
316
|
+
const closed = new Promise((resolve) => {
|
|
317
|
+
child.once("error", (error) => {
|
|
318
|
+
spawnError = error;
|
|
319
|
+
});
|
|
320
|
+
child.once("close", (code, signal) => {
|
|
321
|
+
resolve(spawnError ? { code, error: spawnError, signal } : { code, signal });
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
closed,
|
|
326
|
+
readable: child.stdout,
|
|
327
|
+
stderr: child.stderr,
|
|
328
|
+
writable: child.stdin,
|
|
329
|
+
closeInput: () => child.stdin.end(),
|
|
330
|
+
terminate: () => {
|
|
331
|
+
child.kill();
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
export class DeviceRailClient {
|
|
336
|
+
#closeGraceMs;
|
|
337
|
+
#decoder;
|
|
338
|
+
#maxApplicationRequests;
|
|
339
|
+
#maxAbandonedRequests;
|
|
340
|
+
#maxPendingRequests;
|
|
341
|
+
#pending = new Map();
|
|
342
|
+
#requestPrefix = randomUUID();
|
|
343
|
+
#stderr;
|
|
344
|
+
#transport;
|
|
345
|
+
#transportClosure;
|
|
346
|
+
#writes;
|
|
347
|
+
#applicationPending = 0;
|
|
348
|
+
#abandonedRequests = new Map();
|
|
349
|
+
#automaticCancelTargets = new Map();
|
|
350
|
+
#cancelBacklog = new Map();
|
|
351
|
+
#cancelProgressWaitScheduled = false;
|
|
352
|
+
#closePromise;
|
|
353
|
+
#enabledFeatures = new Set();
|
|
354
|
+
#helloClient;
|
|
355
|
+
#helloProtocol;
|
|
356
|
+
#nextRequest = 1n;
|
|
357
|
+
#readableEnded = false;
|
|
358
|
+
#state = "awaitingHello";
|
|
359
|
+
#terminalError;
|
|
360
|
+
#transportErrorsGuarded = false;
|
|
361
|
+
#ignoreLateTransportError = () => { };
|
|
362
|
+
#onReadableData = (chunk) => {
|
|
363
|
+
try {
|
|
364
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
365
|
+
for (const line of this.#decoder.push(bytes)) {
|
|
366
|
+
if (line.length === 0) {
|
|
367
|
+
throw new ProtocolViolationError("response stream contains an empty NDJSON frame");
|
|
368
|
+
}
|
|
369
|
+
this.#acceptResponse(parseResponse(line));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch (cause) {
|
|
373
|
+
this.#fail(cause instanceof Error ? cause : new ProtocolViolationError("response failed"));
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
#onReadableEnd = () => {
|
|
377
|
+
try {
|
|
378
|
+
this.#decoder.end();
|
|
379
|
+
}
|
|
380
|
+
catch (cause) {
|
|
381
|
+
this.#fail(cause instanceof Error ? cause : new ProtocolViolationError("response ended"));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this.#readableEnded = true;
|
|
385
|
+
if (this.#state !== "failed" &&
|
|
386
|
+
this.#state !== "closed" &&
|
|
387
|
+
!(this.#state === "closing" && this.#pending.size === 0)) {
|
|
388
|
+
const diagnostic = this.#stderr.text.trim();
|
|
389
|
+
this.#fail(new TransportClosedError(`response stream ended before the client completed${diagnostic ? `; stderr: ${diagnostic}` : ""}`));
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
#onReadableClose = () => {
|
|
393
|
+
if (!this.#readableEnded && this.#state !== "closed" && this.#state !== "failed") {
|
|
394
|
+
this.#fail(new TransportClosedError("response stream closed before a clean EOF"));
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
#onReadableError = (cause) => {
|
|
398
|
+
this.#fail(new TransportClosedError("response stream failed", { cause }));
|
|
399
|
+
};
|
|
400
|
+
constructor(transport, options = {}) {
|
|
401
|
+
const maxFrameBytes = positiveSafeInteger(options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES, "maxFrameBytes");
|
|
402
|
+
const maxPendingRequests = positiveSafeInteger(options.maxPendingRequests ?? 256, "maxPendingRequests");
|
|
403
|
+
if (maxPendingRequests < 2) {
|
|
404
|
+
throw new RangeError("maxPendingRequests must be at least 2 to reserve request.cancel capacity");
|
|
405
|
+
}
|
|
406
|
+
const closeGraceMs = positiveSafeInteger(options.closeGraceMs ?? 7_000, "closeGraceMs");
|
|
407
|
+
const stderrTailBytes = positiveSafeInteger(options.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES, "stderrTailBytes");
|
|
408
|
+
this.#transport = transport;
|
|
409
|
+
this.#decoder = new NdjsonDecoder({ maxFrameBytes });
|
|
410
|
+
this.#maxPendingRequests = maxPendingRequests;
|
|
411
|
+
this.#maxAbandonedRequests = maxPendingRequests;
|
|
412
|
+
const cancellationReserve = Math.min(16, Math.max(1, Math.floor(maxPendingRequests / 4)));
|
|
413
|
+
this.#maxApplicationRequests = maxPendingRequests - cancellationReserve;
|
|
414
|
+
this.#closeGraceMs = closeGraceMs;
|
|
415
|
+
this.#writes = new NdjsonWriteQueue(transport.writable, {
|
|
416
|
+
maxFrameBytes,
|
|
417
|
+
...(options.maxQueuedBytes === undefined
|
|
418
|
+
? {}
|
|
419
|
+
: { maxQueuedBytes: options.maxQueuedBytes }),
|
|
420
|
+
...(options.maxQueuedFrames === undefined
|
|
421
|
+
? {}
|
|
422
|
+
: { maxQueuedFrames: options.maxQueuedFrames }),
|
|
423
|
+
});
|
|
424
|
+
void this.#writes.failure.then((error) => this.#fail(error));
|
|
425
|
+
this.#stderr = new BoundedStderrTail(transport.stderr, stderrTailBytes);
|
|
426
|
+
this.#transportClosure = transport.closed.then((closure) => {
|
|
427
|
+
this.#transportClosed(closure);
|
|
428
|
+
return closure;
|
|
429
|
+
}, (cause) => {
|
|
430
|
+
const error = new TransportClosedError("transport closure promise rejected", {
|
|
431
|
+
cause,
|
|
432
|
+
});
|
|
433
|
+
this.#fail(error);
|
|
434
|
+
throw error;
|
|
435
|
+
});
|
|
436
|
+
void this.#transportClosure.catch(() => { });
|
|
437
|
+
transport.readable.on("data", this.#onReadableData);
|
|
438
|
+
transport.readable.once("end", this.#onReadableEnd);
|
|
439
|
+
transport.readable.once("close", this.#onReadableClose);
|
|
440
|
+
transport.readable.once("error", this.#onReadableError);
|
|
441
|
+
}
|
|
442
|
+
static async spawn(options) {
|
|
443
|
+
let child;
|
|
444
|
+
let client;
|
|
445
|
+
try {
|
|
446
|
+
child = spawn(options.command, [...(options.args ?? [])], {
|
|
447
|
+
...options.spawn,
|
|
448
|
+
shell: false,
|
|
449
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
450
|
+
});
|
|
451
|
+
client = new DeviceRailClient(processTransport(child), options);
|
|
452
|
+
await client.hello(options.hello);
|
|
453
|
+
return client;
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
if (client) {
|
|
457
|
+
await client.close().catch(() => { });
|
|
458
|
+
}
|
|
459
|
+
else if (child) {
|
|
460
|
+
try {
|
|
461
|
+
child.stdin.end();
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// Best-effort cleanup while preserving the original construction error.
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
child.kill();
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// Best-effort cleanup while preserving the original construction error.
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
get enabledFeatures() {
|
|
477
|
+
return new Set(this.#enabledFeatures);
|
|
478
|
+
}
|
|
479
|
+
get pendingRequests() {
|
|
480
|
+
return this.#pending.size;
|
|
481
|
+
}
|
|
482
|
+
get state() {
|
|
483
|
+
return this.#state;
|
|
484
|
+
}
|
|
485
|
+
get stderrTail() {
|
|
486
|
+
return this.#stderr.text;
|
|
487
|
+
}
|
|
488
|
+
async hello(params) {
|
|
489
|
+
if (this.#state !== "awaitingHello") {
|
|
490
|
+
throw new HandshakeStateError(`system.hello is not allowed while client is ${this.#state}`);
|
|
491
|
+
}
|
|
492
|
+
const offer = snapshotHelloOffer(params);
|
|
493
|
+
this.#state = "helloInFlight";
|
|
494
|
+
try {
|
|
495
|
+
const rawResult = await this.#start("system.hello", params, undefined, true, false).result;
|
|
496
|
+
let result;
|
|
497
|
+
try {
|
|
498
|
+
result = validateHelloResult(rawResult, offer);
|
|
499
|
+
}
|
|
500
|
+
catch (cause) {
|
|
501
|
+
const error = cause instanceof Error
|
|
502
|
+
? cause
|
|
503
|
+
: new ProtocolViolationError("system.hello returned an invalid result");
|
|
504
|
+
this.#fail(error);
|
|
505
|
+
throw error;
|
|
506
|
+
}
|
|
507
|
+
this.#enabledFeatures = new Set(result.features.enabled);
|
|
508
|
+
this.#helloClient = offer.client;
|
|
509
|
+
this.#helloProtocol = { ...result.protocol.selected };
|
|
510
|
+
if (this.#state === "helloInFlight") {
|
|
511
|
+
this.#state = "ready";
|
|
512
|
+
}
|
|
513
|
+
else if (this.#state === "failed" || this.#state === "closed") {
|
|
514
|
+
throw this.#terminalError ?? new TransportClosedError();
|
|
515
|
+
}
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
if (this.#state === "helloInFlight") {
|
|
520
|
+
this.#state = "awaitingHello";
|
|
521
|
+
}
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async call(method, ...args) {
|
|
526
|
+
return await this.beginCall(method, ...args).result;
|
|
527
|
+
}
|
|
528
|
+
async openEventStream(params, options = {}) {
|
|
529
|
+
if (this.#state !== "ready" || !this.#helloClient || !this.#helloProtocol) {
|
|
530
|
+
throw new HandshakeStateError(`events.stream.open is not allowed while client is ${this.#state}`);
|
|
531
|
+
}
|
|
532
|
+
return await connectEventStream(async (sessionId, originPolicy, signal) => {
|
|
533
|
+
const handle = this.beginCall("events.stream.open", {
|
|
534
|
+
originPolicy,
|
|
535
|
+
sessionId,
|
|
536
|
+
});
|
|
537
|
+
let abortListener;
|
|
538
|
+
if (signal && this.#enabledFeatures.has(REQUEST_CONTROL_FEATURE)) {
|
|
539
|
+
abortListener = () => {
|
|
540
|
+
void handle.cancel().catch(() => { });
|
|
541
|
+
this.#abandonRequest(handle.id, new EventStreamAbortedError());
|
|
542
|
+
};
|
|
543
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
544
|
+
}
|
|
545
|
+
else if (signal) {
|
|
546
|
+
abortListener = () => {
|
|
547
|
+
this.#abandonRequest(handle.id, new EventStreamAbortedError());
|
|
548
|
+
};
|
|
549
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
return await handle.result;
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
if (abortListener) {
|
|
556
|
+
signal?.removeEventListener("abort", abortListener);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}, this.#helloClient, this.#helloProtocol, params, options);
|
|
560
|
+
}
|
|
561
|
+
beginCall(method, ...args) {
|
|
562
|
+
const [params, options] = args;
|
|
563
|
+
if (method === "system.hello") {
|
|
564
|
+
throw new HandshakeStateError("system.hello must be sent through hello()");
|
|
565
|
+
}
|
|
566
|
+
if (method === "events.subscribe") {
|
|
567
|
+
throw new HandshakeStateError("events.subscribe is available only inside the event WebSocket handshake");
|
|
568
|
+
}
|
|
569
|
+
return this.#start(method, params, options, false, method !== "request.cancel");
|
|
570
|
+
}
|
|
571
|
+
async cancel(requestId) {
|
|
572
|
+
const validatedId = parseRpcId(requestId, "request.cancel requestId");
|
|
573
|
+
return await this.call("request.cancel", { requestId: validatedId });
|
|
574
|
+
}
|
|
575
|
+
close() {
|
|
576
|
+
this.#closePromise ??= this.#close();
|
|
577
|
+
return this.#closePromise;
|
|
578
|
+
}
|
|
579
|
+
#start(method, params, options, handshake, application, automaticCancelTarget) {
|
|
580
|
+
if (!handshake && this.#state !== "ready") {
|
|
581
|
+
throw new HandshakeStateError(`${method} is not allowed while client is ${this.#state}`);
|
|
582
|
+
}
|
|
583
|
+
const signal = options?.signal;
|
|
584
|
+
if (signal?.aborted) {
|
|
585
|
+
throw new RequestAbortedError();
|
|
586
|
+
}
|
|
587
|
+
this.#checkFeature(method, params, options);
|
|
588
|
+
if (this.#pending.size >= this.#maxPendingRequests) {
|
|
589
|
+
throw new PendingRequestLimitError(this.#maxPendingRequests);
|
|
590
|
+
}
|
|
591
|
+
if (application && this.#applicationPending >= this.#maxApplicationRequests) {
|
|
592
|
+
throw new PendingRequestLimitError(this.#maxApplicationRequests);
|
|
593
|
+
}
|
|
594
|
+
const id = `${this.#requestPrefix}:${this.#nextRequest.toString()}`;
|
|
595
|
+
this.#nextRequest += 1n;
|
|
596
|
+
const request = { id, jsonrpc: "2.0", method };
|
|
597
|
+
if (params !== undefined) {
|
|
598
|
+
if (!isObject(params) && !(Array.isArray(params) && params.length === 0)) {
|
|
599
|
+
throw new ProtocolViolationError(`${method} params must be an object or empty array`);
|
|
600
|
+
}
|
|
601
|
+
request.params = params;
|
|
602
|
+
}
|
|
603
|
+
const timeoutMs = options?.timeoutMs;
|
|
604
|
+
if (timeoutMs !== undefined) {
|
|
605
|
+
request.timeoutMs = positiveSafeInteger(timeoutMs, "timeoutMs");
|
|
606
|
+
}
|
|
607
|
+
const line = serializeRequest(request);
|
|
608
|
+
let pending;
|
|
609
|
+
const result = new Promise((resolve, reject) => {
|
|
610
|
+
pending = {
|
|
611
|
+
application,
|
|
612
|
+
method,
|
|
613
|
+
reject,
|
|
614
|
+
resolve: (value) => resolve(value),
|
|
615
|
+
};
|
|
616
|
+
});
|
|
617
|
+
const key = rpcIdKey(id);
|
|
618
|
+
this.#pending.set(key, pending);
|
|
619
|
+
if (automaticCancelTarget !== undefined) {
|
|
620
|
+
this.#automaticCancelTargets.set(key, automaticCancelTarget);
|
|
621
|
+
}
|
|
622
|
+
if (application) {
|
|
623
|
+
this.#applicationPending += 1;
|
|
624
|
+
}
|
|
625
|
+
let abortListener;
|
|
626
|
+
if (signal) {
|
|
627
|
+
abortListener = () => {
|
|
628
|
+
this.#scheduleAutomaticCancel(id);
|
|
629
|
+
};
|
|
630
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
631
|
+
void result.finally(() => signal.removeEventListener("abort", abortListener)).catch(() => { });
|
|
632
|
+
}
|
|
633
|
+
const writeProgress = this.#writes.progressVersion;
|
|
634
|
+
void this.#writes.enqueue(line).catch((error) => {
|
|
635
|
+
const automaticCancelTarget = this.#automaticCancelTargets.get(key);
|
|
636
|
+
const removed = this.#takePending(key);
|
|
637
|
+
if (!removed) {
|
|
638
|
+
this.#abandonedRequests.delete(key);
|
|
639
|
+
}
|
|
640
|
+
if (removed) {
|
|
641
|
+
removed.reject(error instanceof Error ? error : new TransportClosedError());
|
|
642
|
+
}
|
|
643
|
+
if (error instanceof WriteQueueOverflowError) {
|
|
644
|
+
if (automaticCancelTarget !== undefined &&
|
|
645
|
+
this.#state === "ready" &&
|
|
646
|
+
this.#pending.has(rpcIdKey(automaticCancelTarget))) {
|
|
647
|
+
this.#cancelBacklog.set(rpcIdKey(automaticCancelTarget), automaticCancelTarget);
|
|
648
|
+
}
|
|
649
|
+
this.#scheduleCancelPumpAfterWriteProgress(writeProgress);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (!(error instanceof WriteFrameTooLargeError)) {
|
|
653
|
+
this.#fail(error instanceof Error ? error : new TransportClosedError());
|
|
654
|
+
}
|
|
655
|
+
if (automaticCancelTarget !== undefined && error instanceof WriteFrameTooLargeError) {
|
|
656
|
+
this.#fail(error);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
this.#pumpCancelBacklog();
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
return {
|
|
663
|
+
id,
|
|
664
|
+
result,
|
|
665
|
+
cancel: () => this.cancel(id),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
#checkFeature(method, params, options) {
|
|
669
|
+
if (method === "system.hello") {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const clientMethod = method;
|
|
673
|
+
const required = requiredFeatures[clientMethod];
|
|
674
|
+
if (required && !this.#enabledFeatures.has(required)) {
|
|
675
|
+
throw new FeatureNotNegotiatedError(method, required);
|
|
676
|
+
}
|
|
677
|
+
const usesPagedSessionExport = method === "session.export" &&
|
|
678
|
+
isObject(params) &&
|
|
679
|
+
(params.afterSequence !== undefined || params.limit !== undefined);
|
|
680
|
+
if (usesPagedSessionExport &&
|
|
681
|
+
!this.#enabledFeatures.has(SESSION_EXPORT_PAGE_FEATURE)) {
|
|
682
|
+
throw new FeatureNotNegotiatedError(method, SESSION_EXPORT_PAGE_FEATURE);
|
|
683
|
+
}
|
|
684
|
+
const usesSemanticAction = method === "device.execute" &&
|
|
685
|
+
isObject(params) &&
|
|
686
|
+
typeof params.name === "string" &&
|
|
687
|
+
semanticActionNames.has(params.name);
|
|
688
|
+
if (usesSemanticAction && !this.#enabledFeatures.has(SEMANTIC_ACTIONS_FEATURE)) {
|
|
689
|
+
throw new FeatureNotNegotiatedError(method, SEMANTIC_ACTIONS_FEATURE);
|
|
690
|
+
}
|
|
691
|
+
const signal = options?.signal;
|
|
692
|
+
const timeoutMs = options?.timeoutMs;
|
|
693
|
+
if ((signal !== undefined || timeoutMs !== undefined) && !timeoutMethods.has(clientMethod)) {
|
|
694
|
+
throw new ProtocolViolationError(`${method} does not support timeout or cancellation options`);
|
|
695
|
+
}
|
|
696
|
+
const actionTimeoutMs = method === "device.execute" && isObject(params) ? params.actionTimeoutMs : undefined;
|
|
697
|
+
if (actionTimeoutMs !== undefined) {
|
|
698
|
+
if (typeof actionTimeoutMs !== "number") {
|
|
699
|
+
throw new RangeError("actionTimeoutMs must be a positive safe integer");
|
|
700
|
+
}
|
|
701
|
+
positiveSafeInteger(actionTimeoutMs, "actionTimeoutMs");
|
|
702
|
+
}
|
|
703
|
+
const usesRequestControl = signal !== undefined || timeoutMs !== undefined || actionTimeoutMs !== undefined;
|
|
704
|
+
if (usesRequestControl && !this.#enabledFeatures.has(REQUEST_CONTROL_FEATURE)) {
|
|
705
|
+
throw new FeatureNotNegotiatedError(method, REQUEST_CONTROL_FEATURE);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
#acceptResponse(response) {
|
|
709
|
+
const key = rpcIdKey(response.id);
|
|
710
|
+
const pending = this.#pending.get(key);
|
|
711
|
+
const abandonedMethod = this.#abandonedRequests.get(key);
|
|
712
|
+
if (!pending && abandonedMethod === undefined) {
|
|
713
|
+
throw new ProtocolViolationError(`response references an unknown or completed id: ${String(response.id)}`);
|
|
714
|
+
}
|
|
715
|
+
const method = pending?.method ?? abandonedMethod;
|
|
716
|
+
if (method === undefined) {
|
|
717
|
+
throw new ProtocolViolationError(`response has no tracked method for id: ${String(response.id)}`);
|
|
718
|
+
}
|
|
719
|
+
validateRpcResponse(method, response.envelope);
|
|
720
|
+
if (abandonedMethod !== undefined) {
|
|
721
|
+
this.#abandonedRequests.delete(key);
|
|
722
|
+
this.#pumpCancelBacklog();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (!pending) {
|
|
726
|
+
throw new ProtocolViolationError(`response references an unknown or completed id: ${String(response.id)}`);
|
|
727
|
+
}
|
|
728
|
+
this.#takePending(key);
|
|
729
|
+
if (response.kind === "error") {
|
|
730
|
+
pending.reject(new RpcRemoteError(response.id, response.error));
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
pending.resolve(response.result);
|
|
734
|
+
}
|
|
735
|
+
this.#pumpCancelBacklog();
|
|
736
|
+
}
|
|
737
|
+
#takePending(key) {
|
|
738
|
+
const pending = this.#pending.get(key);
|
|
739
|
+
if (!pending) {
|
|
740
|
+
return undefined;
|
|
741
|
+
}
|
|
742
|
+
this.#pending.delete(key);
|
|
743
|
+
this.#cancelBacklog.delete(key);
|
|
744
|
+
this.#automaticCancelTargets.delete(key);
|
|
745
|
+
if (pending.application) {
|
|
746
|
+
this.#applicationPending -= 1;
|
|
747
|
+
}
|
|
748
|
+
return pending;
|
|
749
|
+
}
|
|
750
|
+
#abandonRequest(requestId, error) {
|
|
751
|
+
const key = rpcIdKey(requestId);
|
|
752
|
+
const pending = this.#takePending(key);
|
|
753
|
+
if (!pending) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (this.#abandonedRequests.size >= this.#maxAbandonedRequests) {
|
|
757
|
+
pending.reject(error);
|
|
758
|
+
this.#fail(new TransportClosedError(`the client exceeded its ${this.#maxAbandonedRequests}-request late-response budget`));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
this.#abandonedRequests.set(key, pending.method);
|
|
762
|
+
pending.reject(error);
|
|
763
|
+
this.#pumpCancelBacklog();
|
|
764
|
+
}
|
|
765
|
+
#scheduleAutomaticCancel(requestId) {
|
|
766
|
+
if (this.#state !== "ready" ||
|
|
767
|
+
!this.#enabledFeatures.has(REQUEST_CONTROL_FEATURE) ||
|
|
768
|
+
!this.#pending.has(rpcIdKey(requestId))) {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
this.#cancelBacklog.set(rpcIdKey(requestId), requestId);
|
|
772
|
+
this.#pumpCancelBacklog();
|
|
773
|
+
}
|
|
774
|
+
#pumpCancelBacklog() {
|
|
775
|
+
if (this.#state !== "ready" || !this.#enabledFeatures.has(REQUEST_CONTROL_FEATURE)) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
while (this.#pending.size < this.#maxPendingRequests) {
|
|
779
|
+
const next = this.#cancelBacklog.entries().next().value;
|
|
780
|
+
if (!next) {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const [targetKey, requestId] = next;
|
|
784
|
+
this.#cancelBacklog.delete(targetKey);
|
|
785
|
+
if (!this.#pending.has(targetKey)) {
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
const handle = this.#start("request.cancel", { requestId }, undefined, false, false, requestId);
|
|
790
|
+
void handle.result.catch(() => { });
|
|
791
|
+
}
|
|
792
|
+
catch (cause) {
|
|
793
|
+
if (cause instanceof PendingRequestLimitError) {
|
|
794
|
+
this.#cancelBacklog.set(targetKey, requestId);
|
|
795
|
+
}
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
#scheduleCancelPumpAfterWriteProgress(writeProgress) {
|
|
801
|
+
if (this.#cancelProgressWaitScheduled || this.#cancelBacklog.size === 0) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
this.#cancelProgressWaitScheduled = true;
|
|
805
|
+
void this.#writes.waitForProgress(writeProgress).then(() => {
|
|
806
|
+
this.#cancelProgressWaitScheduled = false;
|
|
807
|
+
this.#pumpCancelBacklog();
|
|
808
|
+
}, () => {
|
|
809
|
+
this.#cancelProgressWaitScheduled = false;
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
async #close() {
|
|
813
|
+
if (this.#state === "closed") {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (this.#state === "failed") {
|
|
817
|
+
throw this.#terminalError ?? new TransportClosedError();
|
|
818
|
+
}
|
|
819
|
+
this.#state = "closing";
|
|
820
|
+
this.#cancelBacklog.clear();
|
|
821
|
+
let timeout;
|
|
822
|
+
try {
|
|
823
|
+
await Promise.race([
|
|
824
|
+
(async () => {
|
|
825
|
+
await this.#writes.close();
|
|
826
|
+
try {
|
|
827
|
+
this.#transport.closeInput();
|
|
828
|
+
}
|
|
829
|
+
catch (cause) {
|
|
830
|
+
throw new TransportClosedError("failed to close daemon stdin", { cause });
|
|
831
|
+
}
|
|
832
|
+
await this.#transportClosure;
|
|
833
|
+
})(),
|
|
834
|
+
new Promise((_resolve, reject) => {
|
|
835
|
+
timeout = setTimeout(() => {
|
|
836
|
+
const error = new TransportClosedError(`daemon did not drain and close within ${this.#closeGraceMs} ms`);
|
|
837
|
+
this.#fail(error);
|
|
838
|
+
reject(error);
|
|
839
|
+
}, this.#closeGraceMs);
|
|
840
|
+
}),
|
|
841
|
+
]);
|
|
842
|
+
}
|
|
843
|
+
catch (cause) {
|
|
844
|
+
const error = cause instanceof Error
|
|
845
|
+
? cause
|
|
846
|
+
: new TransportClosedError("daemon close failed", { cause });
|
|
847
|
+
this.#fail(error);
|
|
848
|
+
throw this.#terminalError ?? error;
|
|
849
|
+
}
|
|
850
|
+
finally {
|
|
851
|
+
if (timeout) {
|
|
852
|
+
clearTimeout(timeout);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (this.#terminalError) {
|
|
856
|
+
throw this.#terminalError;
|
|
857
|
+
}
|
|
858
|
+
if (this.state !== "closed") {
|
|
859
|
+
const error = new TransportClosedError("daemon transport closed without completing shutdown");
|
|
860
|
+
this.#fail(error, false);
|
|
861
|
+
throw error;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
#transportClosed(closure) {
|
|
865
|
+
if (this.#state === "closed" || this.#state === "failed") {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (!this.#readableEnded) {
|
|
869
|
+
try {
|
|
870
|
+
this.#decoder.end();
|
|
871
|
+
this.#readableEnded = true;
|
|
872
|
+
}
|
|
873
|
+
catch (cause) {
|
|
874
|
+
this.#fail(cause instanceof Error
|
|
875
|
+
? cause
|
|
876
|
+
: new ProtocolViolationError("response stream ended with an invalid frame"), false);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const normal = closure.error === undefined && closure.code === 0 && closure.signal === null;
|
|
881
|
+
if (normal && this.#state === "closing" && this.#pending.size === 0) {
|
|
882
|
+
this.#state = "closed";
|
|
883
|
+
this.#cleanup();
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const diagnostic = this.#stderr.text.trim();
|
|
887
|
+
const error = new TransportClosedError(`daemon closed with code ${String(closure.code)} and signal ${String(closure.signal)}${diagnostic ? `; stderr: ${diagnostic}` : ""}`, closure.error ? { cause: closure.error } : undefined);
|
|
888
|
+
this.#fail(error, false);
|
|
889
|
+
}
|
|
890
|
+
#fail(error, terminate = true) {
|
|
891
|
+
if (this.#state === "failed" || this.#state === "closed") {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
this.#terminalError = error;
|
|
895
|
+
this.#state = "failed";
|
|
896
|
+
this.#writes.fail(error);
|
|
897
|
+
this.#cancelBacklog.clear();
|
|
898
|
+
this.#abandonedRequests.clear();
|
|
899
|
+
this.#automaticCancelTargets.clear();
|
|
900
|
+
for (const pending of this.#pending.values()) {
|
|
901
|
+
pending.reject(error);
|
|
902
|
+
}
|
|
903
|
+
this.#pending.clear();
|
|
904
|
+
this.#applicationPending = 0;
|
|
905
|
+
if (terminate) {
|
|
906
|
+
try {
|
|
907
|
+
this.#transport.terminate?.();
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
// Transport termination is best effort; all requests are already settled.
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
this.#cleanup();
|
|
914
|
+
}
|
|
915
|
+
#cleanup() {
|
|
916
|
+
this.#abandonedRequests.clear();
|
|
917
|
+
this.#transport.readable.off("data", this.#onReadableData);
|
|
918
|
+
this.#transport.readable.off("end", this.#onReadableEnd);
|
|
919
|
+
this.#transport.readable.off("close", this.#onReadableClose);
|
|
920
|
+
this.#transport.readable.off("error", this.#onReadableError);
|
|
921
|
+
this.#stderr.stop();
|
|
922
|
+
if (!this.#transportErrorsGuarded) {
|
|
923
|
+
this.#transportErrorsGuarded = true;
|
|
924
|
+
this.#transport.readable.on("error", this.#ignoreLateTransportError);
|
|
925
|
+
this.#transport.stderr?.on("error", this.#ignoreLateTransportError);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|