-
Notifications
You must be signed in to change notification settings - Fork 174
Expand file tree
/
Copy pathwebSocketDapAdapter.ts
More file actions
300 lines (264 loc) · 9.41 KB
/
webSocketDapAdapter.ts
File metadata and controls
300 lines (264 loc) · 9.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
import * as vscode from "vscode";
import WebSocket from "ws";
import {log, logDebug, logError} from "../log";
/**
* Interval between websocket ping frames, matching the proven keepalive
* behaviour in gh-actions-debugger.
*/
const PING_INTERVAL_MS = 25_000;
const CONNECT_TIMEOUT_MS = 30_000;
/**
* Inline debug adapter that speaks DAP over a websocket. DAP JSON payloads
* are sent as individual text messages — no Content-Length framing. This
* matches the runner's WebSocketDapBridge and the gh-actions-debugger CLI.
*/
export class WebSocketDapAdapter implements vscode.DebugAdapter {
private readonly _onDidSendMessage = new vscode.EventEmitter<vscode.DebugProtocolMessage>();
readonly onDidSendMessage: vscode.Event<vscode.DebugProtocolMessage> = this._onDidSendMessage.event;
private _ws: WebSocket | undefined;
private _pingTimer: ReturnType<typeof setInterval> | undefined;
private _replayTimer: ReturnType<typeof setTimeout> | undefined;
private _terminatedFired = false;
private _disposed = false;
/**
* Whether VS Code has completed the DAP initialization handshake. The
* runner sends a `stopped` event immediately on connect (before the client
* sends `configurationDone`), and VS Code ignores `stopped` events that
* arrive before configuration is done. We buffer early `stopped` events
* and replay them once the handshake completes.
*/
private _configurationDone = false;
private _pendingStoppedEvents: vscode.DebugProtocolMessage[] = [];
constructor(private readonly _tunnelUrl: string, private readonly _token: string) {}
async connect(): Promise<void> {
log(`Connecting to debugger tunnel: ${this._tunnelUrl}`);
return new Promise<void>((resolve, reject) => {
let settled = false;
const ws = new WebSocket(this._tunnelUrl, {
headers: {
Authorization: `Bearer ${this._token}`
}
});
const connectTimer = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
ws.terminate();
reject(new Error(`Connection timed out after ${CONNECT_TIMEOUT_MS / 1000}s`));
}
}, CONNECT_TIMEOUT_MS);
const onOpen = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(connectTimer);
cleanup();
log("Connected to debugger tunnel");
this._ws = ws;
this._setupReceiver(ws);
this._startPingLoop(ws);
resolve();
};
const onError = (err: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(connectTimer);
cleanup();
logError(err, "Debugger tunnel connection error");
reject(new Error(`Failed to connect to debugger tunnel: ${err.message}`));
};
const onClose = (code: number, reason: Buffer) => {
if (settled) {
return;
}
settled = true;
clearTimeout(connectTimer);
cleanup();
const reasonStr = reason.toString() || `code ${code}`;
logError(new Error(reasonStr), "Debugger tunnel connection closed before open");
reject(new Error(`Debugger tunnel connection closed: ${reasonStr}`));
};
const cleanup = () => {
ws.removeListener("open", onOpen);
ws.removeListener("error", onError);
ws.removeListener("close", onClose);
};
ws.on("open", onOpen);
ws.on("error", onError);
ws.on("close", onClose);
});
}
handleMessage(message: vscode.DebugProtocolMessage): void {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
logError(new Error("Cannot send — websocket not open"), "Debugger tunnel send failed");
return;
}
const json = JSON.stringify(message);
logDebug(`→ DAP: ${describeDapMessage(message)}`);
try {
this._ws.send(json);
} catch (e) {
logError(e as Error, "Debugger tunnel send threw");
this._fireTerminated();
this.dispose();
}
}
dispose(): void {
if (this._disposed) {
return;
}
this._disposed = true;
this._stopPingLoop();
if (this._replayTimer) {
clearTimeout(this._replayTimer);
this._replayTimer = undefined;
}
if (this._ws) {
try {
this._ws.close(1000, "debug session ended");
} catch {
// ignore close errors during teardown
}
this._ws = undefined;
}
this._onDidSendMessage.dispose();
log("Debugger tunnel connection closed");
}
private _setupReceiver(ws: WebSocket): void {
ws.on("message", (data: WebSocket.Data) => {
if (this._disposed) {
return;
}
const text = typeof data === "string" ? data : data.toString();
let message: vscode.DebugProtocolMessage;
try {
message = JSON.parse(text) as vscode.DebugProtocolMessage;
} catch (e) {
logError(e as Error, "Failed to parse DAP message from tunnel");
return;
}
logDebug(`← DAP: ${describeDapMessage(message)}`);
// Buffer stopped events that arrive before the configurationDone
// response — the runner re-sends the stopped event on connect
// (before the DAP handshake completes) and VS Code drops them.
const m = message as Record<string, unknown>;
if (m.type === "event" && m.event === "stopped" && !this._configurationDone) {
logDebug("Buffering stopped event (configurationDone response not yet received)");
this._pendingStoppedEvents.push(message);
return;
}
// VS Code auto-focuses the top stack frame only if it has a source
// reference. The runner doesn't set one yet (the ADR calls for adding
// the workflow file later). Patch frames so VS Code auto-selects them.
if (m.type === "response" && m.command === "stackTrace") {
patchStackFrameSources(message);
}
this._onDidSendMessage.fire(message);
// When the configurationDone response arrives from the runner,
// replay any stopped events that were buffered during the
// handshake. We use a short delay so VS Code finishes processing
// the configurationDone response before receiving the event.
if (m.type === "response" && m.command === "configurationDone") {
this._configurationDone = true;
if (this._pendingStoppedEvents.length > 0) {
const events = this._pendingStoppedEvents;
this._pendingStoppedEvents = [];
logDebug(`Replaying ${events.length} buffered stopped event(s)`);
this._replayTimer = setTimeout(() => {
this._replayTimer = undefined;
if (this._disposed) return;
for (const evt of events) {
this._onDidSendMessage.fire(evt);
}
}, 50);
}
}
});
ws.on("close", (code: number, reason: Buffer) => {
if (this._disposed) {
return;
}
const reasonStr = reason.toString() || `code ${code}`;
log(`Debugger tunnel closed: ${reasonStr}`);
this._stopPingLoop();
this._fireTerminated();
this.dispose();
});
ws.on("error", (err: Error) => {
logError(err, "Debugger tunnel error");
});
}
private _startPingLoop(ws: WebSocket): void {
this._pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.ping("keepalive");
} catch (e) {
logError(e as Error, "Websocket ping failed");
this._stopPingLoop();
this._fireTerminated();
this.dispose();
}
} else {
this._stopPingLoop();
}
}, PING_INTERVAL_MS);
}
private _stopPingLoop(): void {
if (this._pingTimer !== undefined) {
clearInterval(this._pingTimer);
this._pingTimer = undefined;
}
}
private _fireTerminated(): void {
if (this._terminatedFired) return;
this._terminatedFired = true;
this._onDidSendMessage.fire({
type: "event",
event: "terminated",
seq: 0
} as unknown as vscode.DebugProtocolMessage);
}
}
function describeDapMessage(msg: vscode.DebugProtocolMessage): string {
const m = msg as Record<string, unknown>;
const type = (m.type as string) ?? "unknown";
const detail = (m.command as string) ?? (m.event as string) ?? "";
return detail ? `${type}:${detail}` : type;
}
interface DapStackFrame {
id: number;
name: string;
source?: {name?: string; path?: string; sourceReference?: number; presentationHint?: string};
line: number;
column: number;
presentationHint?: string;
}
/**
* VS Code auto-focuses the top stack frame after a `stopped` event only when
* that frame carries a `source` reference. The runner doesn't set one yet (the
* ADR plans to add the workflow file as source later). Until then, we inject a
* minimal synthetic source so VS Code's auto-focus works.
*/
function patchStackFrameSources(message: vscode.DebugProtocolMessage): void {
const m = message as Record<string, unknown>;
const body = m.body as {stackFrames?: DapStackFrame[]} | undefined;
if (!body?.stackFrames) {
return;
}
for (const frame of body.stackFrames) {
if (!frame.source) {
frame.source = {
name: frame.name,
// A positive sourceReference tells VS Code to use the DAP `source`
// request to fetch content. We reuse the frame id; the runner will
// respond (or fail gracefully) when VS Code asks for it.
sourceReference: frame.id,
presentationHint: "deemphasize"
};
}
}
}