@ian-pascoe/pi-dap 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.
@@ -0,0 +1,1103 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { createServer, createConnection, type Server, type Socket } from "node:net";
4
+ import { dirname } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import type { Readable, Writable } from "node:stream";
7
+ import type { DebugProtocol } from "@vscode/debugprotocol";
8
+ import { type Static, Type } from "typebox";
9
+ import { Value } from "typebox/value";
10
+
11
+ const MAX_DAP_FRAME_BYTES = 8 * 1024 * 1024;
12
+ const MAX_DAP_HEADER_BYTES = 64 * 1024;
13
+ const MAX_ADAPTER_STDERR_BYTES = 1024 * 1024;
14
+ const TCP_RETRY_DELAY_MS = 20;
15
+
16
+ const DapProtocolObjectSchema = Type.Object({}, { additionalProperties: true });
17
+ const DapRequestEnvelopeSchema = Type.Object(
18
+ {
19
+ seq: Type.Integer({ minimum: 1 }),
20
+ type: Type.Literal("request"),
21
+ command: Type.String({ minLength: 1 }),
22
+ arguments: Type.Optional(DapProtocolObjectSchema),
23
+ },
24
+ { additionalProperties: false },
25
+ );
26
+ const DapResponseEnvelopeSchema = Type.Object(
27
+ {
28
+ seq: Type.Integer({ minimum: 1 }),
29
+ type: Type.Literal("response"),
30
+ request_seq: Type.Integer({ minimum: 1 }),
31
+ success: Type.Boolean(),
32
+ command: Type.String({ minLength: 1 }),
33
+ message: Type.Optional(Type.String()),
34
+ body: Type.Optional(DapProtocolObjectSchema),
35
+ },
36
+ { additionalProperties: false },
37
+ );
38
+ const DapEventEnvelopeSchema = Type.Object(
39
+ {
40
+ seq: Type.Integer({ minimum: 1 }),
41
+ type: Type.Literal("event"),
42
+ event: Type.String({ minLength: 1 }),
43
+ body: Type.Optional(DapProtocolObjectSchema),
44
+ },
45
+ { additionalProperties: false },
46
+ );
47
+ const DapEnvelopeSchema = Type.Union([
48
+ DapRequestEnvelopeSchema,
49
+ DapResponseEnvelopeSchema,
50
+ DapEventEnvelopeSchema,
51
+ ]);
52
+
53
+ type ParsedDapEnvelope = Static<typeof DapEnvelopeSchema>;
54
+ type ParsedDapRequest = Static<typeof DapRequestEnvelopeSchema>;
55
+ type ParsedDapResponse = Static<typeof DapResponseEnvelopeSchema>;
56
+
57
+ /** JSON object sent as DAP request arguments or returned as a response body. */
58
+ export type DapProtocolObject = Static<typeof DapProtocolObjectSchema>;
59
+
60
+ /** Stdio or TCP transport used to communicate with one configured Debug Adapter. */
61
+ export type DapProtocolTransport =
62
+ | "stdio"
63
+ | {
64
+ readonly type: "tcp";
65
+ readonly host: string;
66
+ /** Zero asks the operating system to select a local port. */
67
+ readonly port: number;
68
+ };
69
+
70
+ /** Time budgets, in milliseconds, owned by one DAP Protocol Client. */
71
+ export interface DapProtocolClientTimeouts {
72
+ /** Process spawn and TCP connection budget. */
73
+ readonly startupMs: number;
74
+ /** Ordinary DAP request budget. */
75
+ readonly requestMs: number;
76
+ /** Complete graceful and forced process shutdown budget. */
77
+ readonly shutdownMs: number;
78
+ }
79
+
80
+ /** Result returned after handling one Debug Adapter reverse request. */
81
+ export type DapReverseRequestResult =
82
+ | { readonly success: true; readonly body?: DapProtocolObject }
83
+ | { readonly success: false; readonly message: string; readonly body?: DapProtocolObject };
84
+
85
+ /** Process, transport, and protocol configuration for one DAP Protocol Client. */
86
+ export interface DapProtocolClientOptions {
87
+ /** Stable configured Adapter Definition ID. */
88
+ readonly adapterId: string;
89
+ /** Project working directory used as the Debug Adapter process cwd. */
90
+ readonly cwd: string;
91
+ /** Debug Adapter executable, started without a shell. */
92
+ readonly command: string;
93
+ /** Executable arguments; TCP transports replace every `$PORT` substring. */
94
+ readonly args: readonly string[];
95
+ /** Environment overrides; null removes an inherited key. */
96
+ readonly environment: Readonly<Record<string, string | null>>;
97
+ /** Configured stdio or TCP transport. */
98
+ readonly transport: DapProtocolTransport;
99
+ /** Startup, request, and shutdown budgets. */
100
+ readonly timeouts: DapProtocolClientTimeouts;
101
+ /** Session file retaining the latest 1 MiB of Debug Adapter stderr. */
102
+ readonly stderrPath: string;
103
+ /** Cancel Debug Adapter startup without retaining a process or transport. */
104
+ readonly startupSignal?: AbortSignal | undefined;
105
+ /** Handle a Debug Adapter request such as `runInTerminal`. */
106
+ readonly onReverseRequest?:
107
+ | ((
108
+ request: DebugProtocol.Request,
109
+ ) => Promise<DapReverseRequestResult> | DapReverseRequestResult)
110
+ | undefined;
111
+ /** Observe the first terminal process, transport, or protocol failure. */
112
+ readonly onFailure?: ((error: DapProtocolClientError) => void) | undefined;
113
+ }
114
+
115
+ /** Per-request cancellation and timeout controls. */
116
+ export interface DapProtocolRequestOptions {
117
+ /** Abort only this request wait. */
118
+ readonly signal?: AbortSignal | undefined;
119
+ /** Override the configured ordinary request budget. */
120
+ readonly timeoutMs?: number | undefined;
121
+ }
122
+
123
+ /** Event wait controls used by Debug Session choreography. */
124
+ export interface DapProtocolEventWaitOptions {
125
+ /** Abort only this event wait. */
126
+ readonly signal?: AbortSignal | undefined;
127
+ /** Override the configured ordinary request budget. */
128
+ readonly timeoutMs?: number | undefined;
129
+ /** Accept only matching event payloads after matching the event name. */
130
+ readonly predicate?: (event: DebugProtocol.Event) => boolean;
131
+ }
132
+
133
+ /** Overrides used when a TCP Debug Adapter asks for its primary target channel. */
134
+ export interface DapProtocolTargetChannelOptions {
135
+ readonly startupSignal?: AbortSignal | undefined;
136
+ readonly onReverseRequest?: DapProtocolClientOptions["onReverseRequest"];
137
+ readonly onFailure?: DapProtocolClientOptions["onFailure"];
138
+ }
139
+
140
+ /** Classified Debug Adapter process, transport, protocol, timeout, and cancellation failure. */
141
+ export class DapProtocolClientError extends Error {
142
+ readonly _tag = "DapProtocolClientError" as const;
143
+
144
+ /** Construct a searchable DAP client failure that always names its stderr capture. */
145
+ constructor(
146
+ readonly kind:
147
+ | "cancelled"
148
+ | "exit"
149
+ | "protocol"
150
+ | "request"
151
+ | "shutdown"
152
+ | "spawn"
153
+ | "timeout"
154
+ | "transport",
155
+ readonly adapterId: string,
156
+ readonly stderrPath: string,
157
+ message: string,
158
+ options?: ErrorOptions,
159
+ ) {
160
+ super(`DAP Protocol Client: ${message} (adapter ${adapterId}; stderr ${stderrPath})`, options);
161
+ }
162
+ }
163
+
164
+ interface PendingDapRequest {
165
+ readonly command: string;
166
+ readonly resolve: (body: DapProtocolObject | undefined) => void;
167
+ readonly reject: (error: DapProtocolClientError) => void;
168
+ readonly cleanup: () => void;
169
+ }
170
+
171
+ class DapFrameDecoder {
172
+ private buffer = Buffer.alloc(0);
173
+ private contentLength: number | undefined;
174
+
175
+ constructor(private readonly receive: (envelope: ParsedDapEnvelope) => void) {}
176
+
177
+ push(chunk: Buffer): void {
178
+ this.buffer = Buffer.concat([this.buffer, chunk]);
179
+ for (;;) {
180
+ if (this.contentLength === undefined) {
181
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
182
+ if (headerEnd < 0) {
183
+ if (this.buffer.length > MAX_DAP_HEADER_BYTES) {
184
+ throw new Error("DAP frame header exceeds 64 KiB");
185
+ }
186
+ return;
187
+ }
188
+ if (headerEnd > MAX_DAP_HEADER_BYTES) {
189
+ throw new Error("DAP frame header exceeds 64 KiB");
190
+ }
191
+ this.contentLength = parseContentLength(this.buffer.subarray(0, headerEnd));
192
+ this.buffer = this.buffer.subarray(headerEnd + 4);
193
+ }
194
+
195
+ if (this.buffer.length < this.contentLength) return;
196
+ const payload = this.buffer.subarray(0, this.contentLength);
197
+ this.buffer = this.buffer.subarray(this.contentLength);
198
+ this.contentLength = undefined;
199
+ this.receive(parseDapEnvelope(payload));
200
+ }
201
+ }
202
+ }
203
+
204
+ function parseContentLength(header: Buffer): number {
205
+ const fields = new Map<string, string>();
206
+ for (const line of header.toString("ascii").split("\r\n")) {
207
+ const separator = line.indexOf(":");
208
+ if (separator <= 0) throw new Error("DAP frame contains a malformed header");
209
+ const name = line.slice(0, separator).trim().toLowerCase();
210
+ const value = line.slice(separator + 1).trim();
211
+ if (name.length === 0 || value.length === 0 || fields.has(name)) {
212
+ throw new Error("DAP frame contains a malformed or duplicate header");
213
+ }
214
+ fields.set(name, value);
215
+ }
216
+ const rawLength = fields.get("content-length");
217
+ if (rawLength === undefined || !/^(?:0|[1-9]\d*)$/.test(rawLength)) {
218
+ throw new Error("DAP frame is missing a valid Content-Length header");
219
+ }
220
+ const contentLength = Number(rawLength);
221
+ if (!Number.isSafeInteger(contentLength) || contentLength > MAX_DAP_FRAME_BYTES) {
222
+ throw new Error("DAP frame exceeds the 8 MiB limit");
223
+ }
224
+ return contentLength;
225
+ }
226
+
227
+ function parseDapEnvelope(payload: Buffer): ParsedDapEnvelope {
228
+ let value: unknown;
229
+ try {
230
+ value = JSON.parse(payload.toString("utf8"));
231
+ } catch (cause) {
232
+ throw new Error("DAP frame contains malformed JSON", { cause });
233
+ }
234
+ if (!Value.Check(DapEnvelopeSchema, value)) {
235
+ const issue = Value.Errors(DapEnvelopeSchema, value)[0];
236
+ throw new Error(
237
+ `DAP frame contains an invalid protocol envelope${issue?.instancePath === undefined ? "" : ` at ${issue.instancePath || "/"}`}`,
238
+ );
239
+ }
240
+ return value;
241
+ }
242
+
243
+ function dapFrame(message: DebugProtocol.ProtocolMessage): Buffer {
244
+ const payload = Buffer.from(JSON.stringify(message));
245
+ return Buffer.concat([
246
+ Buffer.from(`Content-Length: ${String(payload.length)}\r\n\r\n`, "ascii"),
247
+ payload,
248
+ ]);
249
+ }
250
+
251
+ function isDapStartupCancelled(options: DapProtocolClientOptions): boolean {
252
+ return options.startupSignal?.aborted ?? false;
253
+ }
254
+
255
+ function resolvedAdapterEnvironment(
256
+ configured: Readonly<Record<string, string | null>>,
257
+ ): NodeJS.ProcessEnv {
258
+ const environment: NodeJS.ProcessEnv = { ...process.env };
259
+ for (const [name, value] of Object.entries(configured)) {
260
+ if (value === null) delete environment[name];
261
+ else environment[name] = value;
262
+ }
263
+ return environment;
264
+ }
265
+
266
+ async function allocateTcpPort(host: string): Promise<number> {
267
+ const server = createServer();
268
+ await new Promise<void>((resolve, reject) => {
269
+ const onError = (error: Error) => {
270
+ server.off("listening", onListening);
271
+ reject(error);
272
+ };
273
+ const onListening = () => {
274
+ server.off("error", onError);
275
+ resolve();
276
+ };
277
+ server.once("error", onError);
278
+ server.once("listening", onListening);
279
+ server.listen({ host, port: 0 });
280
+ });
281
+ const address = server.address();
282
+ if (
283
+ !Value.Check(Type.Object({ port: Type.Integer() }, { additionalProperties: true }), address)
284
+ ) {
285
+ await closeServer(server);
286
+ throw new Error("TCP port allocation returned no numeric address");
287
+ }
288
+ const port = address.port;
289
+ await closeServer(server);
290
+ return port;
291
+ }
292
+
293
+ async function closeServer(server: Server): Promise<void> {
294
+ await new Promise<void>((resolve, reject) =>
295
+ server.close((error) => (error === undefined ? resolve() : reject(error))),
296
+ );
297
+ }
298
+
299
+ function waitForChildSpawn(
300
+ child: ChildProcessWithoutNullStreams,
301
+ options: DapProtocolClientOptions,
302
+ ): Promise<void> {
303
+ return new Promise((resolve, reject) => {
304
+ let timer: NodeJS.Timeout | undefined;
305
+ const cleanup = () => {
306
+ if (timer !== undefined) clearTimeout(timer);
307
+ options.startupSignal?.removeEventListener("abort", onAbort);
308
+ child.off("spawn", onSpawn);
309
+ child.off("error", onError);
310
+ };
311
+ const onSpawn = () => {
312
+ cleanup();
313
+ resolve();
314
+ };
315
+ const onError = (cause: Error) => {
316
+ cleanup();
317
+ reject(
318
+ new DapProtocolClientError(
319
+ "spawn",
320
+ options.adapterId,
321
+ options.stderrPath,
322
+ `failed to spawn command ${options.command}`,
323
+ { cause },
324
+ ),
325
+ );
326
+ };
327
+ const onAbort = () => {
328
+ cleanup();
329
+ reject(
330
+ new DapProtocolClientError(
331
+ "cancelled",
332
+ options.adapterId,
333
+ options.stderrPath,
334
+ "Debug Adapter startup was cancelled",
335
+ ),
336
+ );
337
+ };
338
+ child.once("spawn", onSpawn);
339
+ child.once("error", onError);
340
+ options.startupSignal?.addEventListener("abort", onAbort, { once: true });
341
+ timer = setTimeout(() => {
342
+ cleanup();
343
+ reject(
344
+ new DapProtocolClientError(
345
+ "timeout",
346
+ options.adapterId,
347
+ options.stderrPath,
348
+ `startup timed out after ${String(options.timeouts.startupMs)}ms`,
349
+ ),
350
+ );
351
+ }, options.timeouts.startupMs);
352
+ timer.unref();
353
+ if (isDapStartupCancelled(options)) onAbort();
354
+ });
355
+ }
356
+
357
+ async function connectTcpWithRetry(
358
+ host: string,
359
+ port: number,
360
+ child: ChildProcessWithoutNullStreams | undefined,
361
+ options: DapProtocolClientOptions,
362
+ ): Promise<Socket> {
363
+ const deadline = Date.now() + options.timeouts.startupMs;
364
+ let lastCause: unknown;
365
+ while (Date.now() < deadline) {
366
+ if (isDapStartupCancelled(options)) {
367
+ throw new DapProtocolClientError(
368
+ "cancelled",
369
+ options.adapterId,
370
+ options.stderrPath,
371
+ "Debug Adapter startup was cancelled",
372
+ );
373
+ }
374
+ if (child !== undefined && (child.exitCode !== null || child.signalCode !== null)) {
375
+ throw new DapProtocolClientError(
376
+ "exit",
377
+ options.adapterId,
378
+ options.stderrPath,
379
+ `Debug Adapter exited before TCP connection (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
380
+ );
381
+ }
382
+ try {
383
+ return await new Promise<Socket>((resolve, reject) => {
384
+ const socket = createConnection({ host, port });
385
+ const cleanup = () => {
386
+ options.startupSignal?.removeEventListener("abort", onAbort);
387
+ socket.off("connect", onConnect);
388
+ socket.off("error", onError);
389
+ };
390
+ const onConnect = () => {
391
+ cleanup();
392
+ resolve(socket);
393
+ };
394
+ const onError = (error: Error) => {
395
+ cleanup();
396
+ socket.destroy();
397
+ reject(error);
398
+ };
399
+ const onAbort = () => {
400
+ cleanup();
401
+ socket.destroy();
402
+ reject(
403
+ new DapProtocolClientError(
404
+ "cancelled",
405
+ options.adapterId,
406
+ options.stderrPath,
407
+ "Debug Adapter startup was cancelled",
408
+ ),
409
+ );
410
+ };
411
+ socket.once("connect", onConnect);
412
+ socket.once("error", onError);
413
+ options.startupSignal?.addEventListener("abort", onAbort, { once: true });
414
+ if (isDapStartupCancelled(options)) onAbort();
415
+ });
416
+ } catch (cause) {
417
+ if (cause instanceof DapProtocolClientError && cause.kind === "cancelled") throw cause;
418
+ lastCause = cause;
419
+ const remaining = deadline - Date.now();
420
+ if (remaining > 0) await delay(Math.min(TCP_RETRY_DELAY_MS, remaining));
421
+ }
422
+ }
423
+ throw new DapProtocolClientError(
424
+ "timeout",
425
+ options.adapterId,
426
+ options.stderrPath,
427
+ `TCP startup timed out after ${String(options.timeouts.startupMs)}ms connecting to ${host}:${String(port)}`,
428
+ { cause: lastCause },
429
+ );
430
+ }
431
+
432
+ class BoundedAdapterStderr {
433
+ private retained = Buffer.alloc(0);
434
+ private dirty = false;
435
+ private writePromise: Promise<void> | undefined;
436
+
437
+ constructor(
438
+ readable: Readable,
439
+ readonly path: string,
440
+ ) {
441
+ readable.on("data", (chunk: Buffer | string) => {
442
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
443
+ const combined = Buffer.concat([this.retained, bytes]);
444
+ this.retained =
445
+ combined.length <= MAX_ADAPTER_STDERR_BYTES
446
+ ? combined
447
+ : combined.subarray(combined.length - MAX_ADAPTER_STDERR_BYTES);
448
+ this.dirty = true;
449
+ this.writePromise ??= this.writeLatestSnapshots();
450
+ });
451
+ }
452
+
453
+ async flush(): Promise<void> {
454
+ while (this.writePromise !== undefined) await this.writePromise;
455
+ }
456
+
457
+ private async writeLatestSnapshots(): Promise<void> {
458
+ while (this.dirty) {
459
+ this.dirty = false;
460
+ await writeFile(this.path, this.retained);
461
+ }
462
+ this.writePromise = undefined;
463
+ }
464
+ }
465
+
466
+ function processExitPromise(child: ChildProcessWithoutNullStreams): Promise<void> {
467
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
468
+ return new Promise((resolve) => child.once("exit", () => resolve()));
469
+ }
470
+
471
+ function signalOwnedProcessGroup(
472
+ child: ChildProcessWithoutNullStreams,
473
+ signal: NodeJS.Signals,
474
+ ): void {
475
+ if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
476
+ try {
477
+ if (process.platform === "linux") process.kill(-child.pid, signal);
478
+ else child.kill(signal);
479
+ } catch (cause) {
480
+ const code = cause instanceof Error && "code" in cause ? cause.code : undefined;
481
+ if (code !== "ESRCH") throw cause;
482
+ }
483
+ }
484
+
485
+ async function waitUntil(promise: Promise<void>, deadline: number): Promise<boolean> {
486
+ const remaining = deadline - Date.now();
487
+ if (remaining <= 0) return false;
488
+ let timer: NodeJS.Timeout | undefined;
489
+ const timeout = new Promise<false>((resolve) => {
490
+ timer = setTimeout(() => resolve(false), remaining);
491
+ timer.unref();
492
+ });
493
+ try {
494
+ return await Promise.race([promise.then(() => true), timeout]);
495
+ } finally {
496
+ if (timer !== undefined) clearTimeout(timer);
497
+ }
498
+ }
499
+
500
+ /** Owns one framed Debug Adapter transport and its correlated DAP requests. */
501
+ export class DapProtocolClient {
502
+ private readonly pendingRequests = new Map<number, PendingDapRequest>();
503
+ private readonly pendingEventWaitRejectors = new Set<(error: DapProtocolClientError) => void>();
504
+ private readonly abandonedRequestSequences = new Set<number>();
505
+ private readonly eventListeners = new Set<(event: DebugProtocol.Event) => void>();
506
+ private readonly decoder: DapFrameDecoder;
507
+ private nextSequence = 1;
508
+ private failure: DapProtocolClientError | undefined;
509
+ private shuttingDown = false;
510
+ private shutdownPromise: Promise<void> | undefined;
511
+
512
+ private constructor(
513
+ private readonly options: DapProtocolClientOptions,
514
+ private readonly child: ChildProcessWithoutNullStreams | undefined,
515
+ private readonly reader: Readable,
516
+ private readonly writer: Writable,
517
+ private readonly socket: Socket | undefined,
518
+ private readonly stderr: BoundedAdapterStderr | undefined,
519
+ readonly selectedPort: number | undefined,
520
+ ) {
521
+ this.decoder = new DapFrameDecoder((envelope) => this.receiveEnvelope(envelope));
522
+ this.reader.on("data", (chunk: Buffer | string) => {
523
+ try {
524
+ this.decoder.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
525
+ } catch (cause) {
526
+ this.fail(
527
+ new DapProtocolClientError(
528
+ "protocol",
529
+ this.adapterId,
530
+ this.stderrPath,
531
+ cause instanceof Error ? cause.message : "failed to parse a DAP frame",
532
+ { cause },
533
+ ),
534
+ );
535
+ }
536
+ });
537
+ this.reader.on("error", (cause) => {
538
+ if (!this.shuttingDown) {
539
+ this.fail(
540
+ new DapProtocolClientError(
541
+ "transport",
542
+ this.adapterId,
543
+ this.stderrPath,
544
+ "Debug Adapter transport failed",
545
+ { cause },
546
+ ),
547
+ );
548
+ }
549
+ });
550
+ this.reader.on("end", () => {
551
+ if (!this.shuttingDown && this.failure === undefined) {
552
+ this.fail(
553
+ new DapProtocolClientError(
554
+ "transport",
555
+ this.adapterId,
556
+ this.stderrPath,
557
+ "Debug Adapter transport ended unexpectedly",
558
+ ),
559
+ );
560
+ }
561
+ });
562
+ if (this.child !== undefined) {
563
+ this.child.on("error", (cause) => {
564
+ if (!this.shuttingDown) {
565
+ this.fail(
566
+ new DapProtocolClientError(
567
+ "spawn",
568
+ this.adapterId,
569
+ this.stderrPath,
570
+ "Debug Adapter process failed",
571
+ { cause },
572
+ ),
573
+ );
574
+ }
575
+ });
576
+ this.child.on("exit", (code, signal) => {
577
+ const error = new DapProtocolClientError(
578
+ "exit",
579
+ this.adapterId,
580
+ this.stderrPath,
581
+ `Debug Adapter exited unexpectedly (code ${String(code)}, signal ${String(signal)})`,
582
+ );
583
+ if (this.shuttingDown) this.rejectPending(error);
584
+ else this.fail(error);
585
+ });
586
+ if (this.child.exitCode !== null || this.child.signalCode !== null) {
587
+ this.fail(
588
+ new DapProtocolClientError(
589
+ "exit",
590
+ this.adapterId,
591
+ this.stderrPath,
592
+ `Debug Adapter exited unexpectedly (code ${String(this.child.exitCode)}, signal ${String(this.child.signalCode)})`,
593
+ ),
594
+ );
595
+ }
596
+ }
597
+ }
598
+
599
+ /** Start a configured Debug Adapter and connect its stdio or TCP transport. */
600
+ static async start(options: DapProtocolClientOptions): Promise<DapProtocolClient> {
601
+ if (isDapStartupCancelled(options)) {
602
+ throw new DapProtocolClientError(
603
+ "cancelled",
604
+ options.adapterId,
605
+ options.stderrPath,
606
+ "Debug Adapter startup was cancelled",
607
+ );
608
+ }
609
+ await mkdir(dirname(options.stderrPath), { recursive: true });
610
+ await writeFile(options.stderrPath, "");
611
+
612
+ let selectedPort: number | undefined;
613
+ let args = [...options.args];
614
+ const environment = resolvedAdapterEnvironment(options.environment);
615
+ if (options.transport === "stdio") {
616
+ if (args.some((argument) => argument.includes("$PORT"))) {
617
+ throw new DapProtocolClientError(
618
+ "transport",
619
+ options.adapterId,
620
+ options.stderrPath,
621
+ "stdio Adapter Definition arguments cannot contain $PORT",
622
+ );
623
+ }
624
+ } else {
625
+ try {
626
+ selectedPort =
627
+ options.transport.port === 0
628
+ ? await allocateTcpPort(options.transport.host)
629
+ : options.transport.port;
630
+ } catch (cause) {
631
+ throw new DapProtocolClientError(
632
+ "transport",
633
+ options.adapterId,
634
+ options.stderrPath,
635
+ "failed to allocate a TCP port",
636
+ { cause },
637
+ );
638
+ }
639
+ if (isDapStartupCancelled(options)) {
640
+ throw new DapProtocolClientError(
641
+ "cancelled",
642
+ options.adapterId,
643
+ options.stderrPath,
644
+ "Debug Adapter startup was cancelled",
645
+ );
646
+ }
647
+ const portText = String(selectedPort);
648
+ args = args.map((argument) => argument.replaceAll("$PORT", portText));
649
+ environment.PORT = portText;
650
+ }
651
+
652
+ let child: ChildProcessWithoutNullStreams;
653
+ try {
654
+ child = spawn(options.command, args, {
655
+ cwd: options.cwd,
656
+ detached: process.platform === "linux",
657
+ env: environment,
658
+ shell: false,
659
+ stdio: "pipe",
660
+ });
661
+ } catch (cause) {
662
+ throw new DapProtocolClientError(
663
+ "spawn",
664
+ options.adapterId,
665
+ options.stderrPath,
666
+ `failed to spawn command ${options.command}`,
667
+ { cause },
668
+ );
669
+ }
670
+ const stderr = new BoundedAdapterStderr(child.stderr, options.stderrPath);
671
+
672
+ try {
673
+ await waitForChildSpawn(child, options);
674
+ if (options.transport === "stdio") {
675
+ return new DapProtocolClient(
676
+ options,
677
+ child,
678
+ child.stdout,
679
+ child.stdin,
680
+ undefined,
681
+ stderr,
682
+ undefined,
683
+ );
684
+ }
685
+ child.stdout.resume();
686
+ const socket = await connectTcpWithRetry(
687
+ options.transport.host,
688
+ selectedPort ?? options.transport.port,
689
+ child,
690
+ options,
691
+ );
692
+ return new DapProtocolClient(options, child, socket, socket, socket, stderr, selectedPort);
693
+ } catch (cause) {
694
+ signalOwnedProcessGroup(child, "SIGTERM");
695
+ await waitUntil(
696
+ processExitPromise(child),
697
+ Date.now() + Math.min(250, options.timeouts.shutdownMs),
698
+ );
699
+ signalOwnedProcessGroup(child, "SIGKILL");
700
+ await stderr.flush();
701
+ if (cause instanceof DapProtocolClientError) throw cause;
702
+ throw new DapProtocolClientError(
703
+ "transport",
704
+ options.adapterId,
705
+ options.stderrPath,
706
+ "failed to start the Debug Adapter transport",
707
+ { cause },
708
+ );
709
+ }
710
+ }
711
+
712
+ /** Stable Adapter Definition ID associated with this client. */
713
+ get adapterId(): string {
714
+ return this.options.adapterId;
715
+ }
716
+
717
+ /** PID of the owned Debug Adapter process. */
718
+ get adapterPid(): number | undefined {
719
+ return this.child?.pid;
720
+ }
721
+
722
+ /** Session path retaining the latest 1 MiB of Debug Adapter stderr. */
723
+ get stderrPath(): string {
724
+ return this.options.stderrPath;
725
+ }
726
+
727
+ /** Whether the transport has failed or shutdown has begun. */
728
+ get isClosed(): boolean {
729
+ return this.failure !== undefined || this.shuttingDown;
730
+ }
731
+
732
+ /** Connect the primary adapter-owned target channel without spawning another process. */
733
+ async connectTargetChannel(
734
+ overrides: DapProtocolTargetChannelOptions = {},
735
+ ): Promise<DapProtocolClient> {
736
+ if (this.options.transport === "stdio" || this.selectedPort === undefined) {
737
+ throw new DapProtocolClientError(
738
+ "transport",
739
+ this.adapterId,
740
+ this.stderrPath,
741
+ "primary target channels require a started TCP Debug Adapter",
742
+ );
743
+ }
744
+ const options: DapProtocolClientOptions = {
745
+ ...this.options,
746
+ startupSignal: overrides.startupSignal,
747
+ onReverseRequest: overrides.onReverseRequest,
748
+ onFailure: overrides.onFailure,
749
+ };
750
+ const socket = await connectTcpWithRetry(
751
+ this.options.transport.host,
752
+ this.selectedPort,
753
+ undefined,
754
+ options,
755
+ );
756
+ return new DapProtocolClient(
757
+ options,
758
+ undefined,
759
+ socket,
760
+ socket,
761
+ socket,
762
+ undefined,
763
+ this.selectedPort,
764
+ );
765
+ }
766
+
767
+ /** Subscribe to parsed Debug Adapter events; returns an unsubscribe operation. */
768
+ onEvent(listener: (event: DebugProtocol.Event) => void): () => void {
769
+ this.eventListeners.add(listener);
770
+ return () => this.eventListeners.delete(listener);
771
+ }
772
+
773
+ /** Wait for one parsed Debug Adapter event by name, predicate, timeout, or cancellation. */
774
+ async waitForEvent<TEvent extends DebugProtocol.Event = DebugProtocol.Event>(
775
+ eventName: string,
776
+ options: DapProtocolEventWaitOptions = {},
777
+ ): Promise<TEvent> {
778
+ this.throwIfUnavailable();
779
+ if (options.signal?.aborted === true) throw this.cancelledError(`waiting for ${eventName}`);
780
+ const timeoutMs = options.timeoutMs ?? this.options.timeouts.requestMs;
781
+ return new Promise<TEvent>((resolve, reject) => {
782
+ let timer: NodeJS.Timeout | undefined;
783
+ const cleanup = () => {
784
+ if (timer !== undefined) clearTimeout(timer);
785
+ options.signal?.removeEventListener("abort", onAbort);
786
+ this.eventListeners.delete(onEvent);
787
+ this.pendingEventWaitRejectors.delete(rejectWait);
788
+ };
789
+ const onEvent = (event: DebugProtocol.Event) => {
790
+ if (event.event !== eventName || options.predicate?.(event) === false) return;
791
+ cleanup();
792
+ // SAFETY: The caller chooses TEvent for the named DAP event; every envelope was parsed before this protocol boundary.
793
+ resolve(event as TEvent);
794
+ };
795
+ const onAbort = () => {
796
+ cleanup();
797
+ reject(this.cancelledError(`waiting for ${eventName}`));
798
+ };
799
+ const rejectWait = (error: DapProtocolClientError) => {
800
+ cleanup();
801
+ reject(error);
802
+ };
803
+ this.eventListeners.add(onEvent);
804
+ this.pendingEventWaitRejectors.add(rejectWait);
805
+ options.signal?.addEventListener("abort", onAbort, { once: true });
806
+ timer = setTimeout(() => {
807
+ cleanup();
808
+ reject(this.timeoutError(`waiting for ${eventName}`, timeoutMs));
809
+ }, timeoutMs);
810
+ timer.unref();
811
+ });
812
+ }
813
+
814
+ /** Send one correlated DAP request and return its successful response body. */
815
+ async request<TBody = unknown>(
816
+ command: string,
817
+ argumentsValue?: DapProtocolObject,
818
+ options: DapProtocolRequestOptions = {},
819
+ ): Promise<TBody> {
820
+ this.throwIfUnavailable();
821
+ if (options.signal?.aborted === true) throw this.cancelledError(`${command} request`);
822
+ const sequence = this.nextSequence++;
823
+ const timeoutMs = options.timeoutMs ?? this.options.timeouts.requestMs;
824
+ const request: DebugProtocol.Request = {
825
+ seq: sequence,
826
+ type: "request",
827
+ command,
828
+ };
829
+ if (argumentsValue !== undefined) request.arguments = argumentsValue;
830
+
831
+ const response = new Promise<DapProtocolObject | undefined>((resolve, reject) => {
832
+ let timer: NodeJS.Timeout | undefined;
833
+ const onAbort = () => {
834
+ this.pendingRequests.delete(sequence);
835
+ this.abandonedRequestSequences.add(sequence);
836
+ cleanup();
837
+ reject(this.cancelledError(`${command} request`));
838
+ };
839
+ const cleanup = () => {
840
+ if (timer !== undefined) clearTimeout(timer);
841
+ options.signal?.removeEventListener("abort", onAbort);
842
+ };
843
+ this.pendingRequests.set(sequence, { command, resolve, reject, cleanup });
844
+ options.signal?.addEventListener("abort", onAbort, { once: true });
845
+ timer = setTimeout(() => {
846
+ this.pendingRequests.delete(sequence);
847
+ this.abandonedRequestSequences.add(sequence);
848
+ cleanup();
849
+ reject(this.timeoutError(`${command} request`, timeoutMs));
850
+ }, timeoutMs);
851
+ timer.unref();
852
+ });
853
+
854
+ try {
855
+ await this.writeMessage(request);
856
+ } catch (cause) {
857
+ const pending = this.pendingRequests.get(sequence);
858
+ this.pendingRequests.delete(sequence);
859
+ pending?.cleanup();
860
+ const error =
861
+ cause instanceof DapProtocolClientError
862
+ ? cause
863
+ : new DapProtocolClientError(
864
+ "transport",
865
+ this.adapterId,
866
+ this.stderrPath,
867
+ `failed to write ${command} request`,
868
+ { cause },
869
+ );
870
+ pending?.reject(error);
871
+ }
872
+
873
+ // SAFETY: DAP command/response body pairing is declared by @vscode/debugprotocol; callers select the body type for the command they sent.
874
+ return (await response) as TBody;
875
+ }
876
+
877
+ /** Attempt DAP terminate/disconnect, then stop the owned Linux process group within shutdownMs. */
878
+ async shutdown(): Promise<void> {
879
+ if (this.shutdownPromise !== undefined) return this.shutdownPromise;
880
+ const shutdown = this.performShutdown();
881
+ this.shutdownPromise = shutdown;
882
+ return shutdown;
883
+ }
884
+
885
+ private async performShutdown(): Promise<void> {
886
+ if (this.shuttingDown) return;
887
+ const wasAvailable = this.failure === undefined;
888
+ const deadline = Date.now() + this.options.timeouts.shutdownMs;
889
+ if (wasAvailable) {
890
+ for (const [command, argumentsValue] of [
891
+ ["terminate", {}],
892
+ ["disconnect", { terminateDebuggee: true }],
893
+ ] as const) {
894
+ const remaining = deadline - Date.now();
895
+ if (remaining <= 0) break;
896
+ try {
897
+ await this.request(command, argumentsValue, {
898
+ timeoutMs: Math.max(
899
+ 1,
900
+ Math.min(this.options.timeouts.requestMs, Math.floor(remaining / 3)),
901
+ ),
902
+ });
903
+ } catch {
904
+ // Shutdown is best effort; process-group ownership is the final guarantee.
905
+ }
906
+ }
907
+ }
908
+ this.shuttingDown = true;
909
+ const shutdownError = new DapProtocolClientError(
910
+ "shutdown",
911
+ this.adapterId,
912
+ this.stderrPath,
913
+ "Debug Adapter client is shutting down",
914
+ );
915
+ this.rejectPending(shutdownError);
916
+ this.rejectEventWaits(shutdownError);
917
+ this.socket?.end();
918
+ if (this.socket === undefined) this.child?.stdin.end();
919
+
920
+ if (this.child === undefined) {
921
+ this.shuttingDown = true;
922
+ this.reader.removeAllListeners();
923
+ this.writer.destroy();
924
+ return;
925
+ }
926
+
927
+ const exit = processExitPromise(this.child);
928
+ const gracefulDeadline = Date.now() + Math.max(0, Math.floor((deadline - Date.now()) / 2));
929
+ if (!(await waitUntil(exit, gracefulDeadline))) {
930
+ signalOwnedProcessGroup(this.child, "SIGTERM");
931
+ const termDeadline = Date.now() + Math.max(0, Math.floor((deadline - Date.now()) / 2));
932
+ if (!(await waitUntil(exit, termDeadline))) {
933
+ signalOwnedProcessGroup(this.child, "SIGKILL");
934
+ await waitUntil(exit, deadline);
935
+ }
936
+ }
937
+ this.reader.removeAllListeners();
938
+ this.writer.destroy();
939
+ await this.stderr?.flush();
940
+ }
941
+
942
+ private receiveEnvelope(envelope: ParsedDapEnvelope): void {
943
+ switch (envelope.type) {
944
+ case "response":
945
+ this.receiveResponse(envelope);
946
+ return;
947
+ case "event":
948
+ this.eventListeners.forEach((listener) => listener(envelope));
949
+ return;
950
+ case "request":
951
+ void this.receiveReverseRequest(envelope);
952
+ return;
953
+ }
954
+ }
955
+
956
+ private receiveResponse(response: ParsedDapResponse): void {
957
+ const pending = this.pendingRequests.get(response.request_seq);
958
+ if (pending === undefined) {
959
+ if (this.abandonedRequestSequences.delete(response.request_seq)) return;
960
+ this.fail(
961
+ new DapProtocolClientError(
962
+ "protocol",
963
+ this.adapterId,
964
+ this.stderrPath,
965
+ `received an unexpected response for request ${String(response.request_seq)}`,
966
+ ),
967
+ );
968
+ return;
969
+ }
970
+ this.pendingRequests.delete(response.request_seq);
971
+ pending.cleanup();
972
+ if (response.command !== pending.command) {
973
+ const error = new DapProtocolClientError(
974
+ "protocol",
975
+ this.adapterId,
976
+ this.stderrPath,
977
+ `response command ${response.command} does not match request ${pending.command}`,
978
+ );
979
+ pending.reject(error);
980
+ this.fail(error);
981
+ return;
982
+ }
983
+ if (!response.success) {
984
+ pending.reject(
985
+ new DapProtocolClientError(
986
+ "request",
987
+ this.adapterId,
988
+ this.stderrPath,
989
+ `${pending.command} request failed${response.message === undefined ? "" : `: ${response.message}`}`,
990
+ ),
991
+ );
992
+ return;
993
+ }
994
+ pending.resolve(response.body);
995
+ }
996
+
997
+ private async receiveReverseRequest(request: ParsedDapRequest): Promise<void> {
998
+ let result: DapReverseRequestResult;
999
+ try {
1000
+ result = (await this.options.onReverseRequest?.(request)) ?? {
1001
+ success: false,
1002
+ message: `unsupported reverse request: ${request.command}`,
1003
+ };
1004
+ } catch (cause) {
1005
+ result = {
1006
+ success: false,
1007
+ message:
1008
+ cause instanceof Error ? cause.message : `reverse request ${request.command} failed`,
1009
+ };
1010
+ }
1011
+ const response: DebugProtocol.Response = {
1012
+ seq: this.nextSequence++,
1013
+ type: "response",
1014
+ request_seq: request.seq,
1015
+ command: request.command,
1016
+ success: result.success,
1017
+ };
1018
+ if (result.body !== undefined) response.body = result.body;
1019
+ if (!result.success) response.message = result.message;
1020
+ try {
1021
+ await this.writeMessage(response);
1022
+ } catch (cause) {
1023
+ this.fail(
1024
+ cause instanceof DapProtocolClientError
1025
+ ? cause
1026
+ : new DapProtocolClientError(
1027
+ "transport",
1028
+ this.adapterId,
1029
+ this.stderrPath,
1030
+ `failed to answer reverse request ${request.command}`,
1031
+ { cause },
1032
+ ),
1033
+ );
1034
+ }
1035
+ }
1036
+
1037
+ private writeMessage(message: DebugProtocol.ProtocolMessage): Promise<void> {
1038
+ this.throwIfUnavailable();
1039
+ return new Promise((resolve, reject) => {
1040
+ this.writer.write(dapFrame(message), (error) => {
1041
+ if (error === null || error === undefined) resolve();
1042
+ else reject(error);
1043
+ });
1044
+ });
1045
+ }
1046
+
1047
+ private throwIfUnavailable(): void {
1048
+ if (this.failure !== undefined) throw this.failure;
1049
+ if (this.shuttingDown) {
1050
+ throw new DapProtocolClientError(
1051
+ "shutdown",
1052
+ this.adapterId,
1053
+ this.stderrPath,
1054
+ "Debug Adapter client is shutting down",
1055
+ );
1056
+ }
1057
+ }
1058
+
1059
+ private fail(error: DapProtocolClientError): void {
1060
+ if (this.failure !== undefined || this.shuttingDown) return;
1061
+ this.failure = error;
1062
+ this.rejectPending(error);
1063
+ this.rejectEventWaits(error);
1064
+ this.socket?.destroy();
1065
+ if (this.socket === undefined && this.child !== undefined) {
1066
+ this.child.stdin.destroy();
1067
+ this.child.stdout.destroy();
1068
+ }
1069
+ if (this.child !== undefined) signalOwnedProcessGroup(this.child, "SIGTERM");
1070
+ this.options.onFailure?.(error);
1071
+ }
1072
+
1073
+ private rejectPending(error: DapProtocolClientError): void {
1074
+ for (const pending of this.pendingRequests.values()) {
1075
+ pending.cleanup();
1076
+ pending.reject(error);
1077
+ }
1078
+ this.pendingRequests.clear();
1079
+ }
1080
+
1081
+ private rejectEventWaits(error: DapProtocolClientError): void {
1082
+ for (const rejectWait of this.pendingEventWaitRejectors) rejectWait(error);
1083
+ this.pendingEventWaitRejectors.clear();
1084
+ }
1085
+
1086
+ private cancelledError(operation: string): DapProtocolClientError {
1087
+ return new DapProtocolClientError(
1088
+ "cancelled",
1089
+ this.adapterId,
1090
+ this.stderrPath,
1091
+ `${operation} was cancelled`,
1092
+ );
1093
+ }
1094
+
1095
+ private timeoutError(operation: string, timeoutMs: number): DapProtocolClientError {
1096
+ return new DapProtocolClientError(
1097
+ "timeout",
1098
+ this.adapterId,
1099
+ this.stderrPath,
1100
+ `${operation} timed out after ${String(timeoutMs)}ms`,
1101
+ );
1102
+ }
1103
+ }