src/vs/platform/agentHost/node/tunnelAgentHostService.ts

390 LOC · 103 covered · 287 uncovered · 18 ranges · 6 concepts · 5 introducers · 4 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the filesrc/vs/platform/agentHost/common/tunnelAgentHost.ts · 276 LOCcommon/tunnelAgentHost.t…tunnelAgentHostService.ts ×1 · 2 introduced LOCtunnelAgentHostService.t…tunnelAgentHostService.ts ×1 · 2 introduced LOCtunnelAgentHostService.t…tunnelAgentHostService.ts ×1 · 1 introduced LOCtunnelAgentHostService.t…tunnelAgentHostService.ts ×1 · 10 introduced LOCtunnelAgentHostService.t…tunnelAgentHostService.test|title=TunnelAgentHostService - withTimeout production constant is large enough to cover SDK keepalive windows|occurrence=1 · 0 introduced LOCtunnelAgentHostService.t…tunnelAgentHostService.ts ×14 · 343 introduced LOCtunnelAgentHostService.t…tunnelAgentHostService.test|title=TunnelAgentHostService - withTimeout production constant is large enough to cover SDK keepalive windows|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/tunnelAgentHostService.test|title=TunnelAgentHostService - withTimeout production constant is large enough to cover SDK keepalive windows|occurrence=1tunnelAgentHostService.t…tunnelAgentHostService.test|title=TunnelAgentHostService - withTimeout rethrows the operation error verbatim when it rejects before the timeout|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/tunnelAgentHostService.test|title=TunnelAgentHostService - withTimeout rethrows the operation error verbatim when it rejects before the timeout|occurrence=1tunnelAgentHostService.t…tunnelAgentHostService.test|title=TunnelAgentHostService - withTimeout returns the operation result when it settles within the timeout|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/tunnelAgentHostService.test|title=TunnelAgentHostService - withTimeout returns the operation result when it settles within the timeout|occurrence=1tunnelAgentHostService.t…tunnelAgentHostService.test|title=TunnelAgentHostService - withTimeout throws a step-named timeout error when the operation hangs past the deadline|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/tunnelAgentHostService.test|title=TunnelAgentHostService - withTimeout throws a step-named timeout error when the operation hangs past the deadline|occurrence=1tunnelAgentHostService.t…Focused file · src/vs/platform/agentHost/node/tunnelAgentHostService.ts · 390 LOCnode/tunnelAgentHostServ…

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- tunnelAgentHostService.ts ×14
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import type { Tunnel } from '@microsoft/dev-tunnels-contracts';
7 > import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management';
8 > import { createHash } from 'crypto';
9 > import type WebSocket from 'ws';
10 > import { Emitter, Event } from '../../../base/common/event.js';
11 > import { Disposable } from '../../../base/common/lifecycle.js';
12 > import { raceTimeout } from '../../../base/common/async.js';
13 > import { generateUuid } from '../../../base/common/uuid.js';
14 > import { ILogService } from '../../log/common/log.js';
15 > import {
16 > ITunnelAgentHostMainService,
17 > TUNNEL_ADDRESS_PREFIX,
18 > TUNNEL_AGENT_HOST_PORT,
19 > TUNNEL_LAUNCHER_LABEL,
20 > TUNNEL_MIN_PROTOCOL_VERSION,
21 > TunnelTags,
22 > type ITunnelConnectResult,
23 > type ITunnelInfo,
24 > type ITunnelRelayMessage,
25 > } from '../common/tunnelAgentHost.js';
26 >
27 > const LOG_PREFIX = '[TunnelAgentHost]';
28 >
29 > /**
30 > * Per-step timeout for the dev-tunnels SDK calls inside {@link TunnelAgentHostMainService.connect}.
31 > *
32 > * Without this, a silently dropped network (TCP half-open, host gone but relay still
33 > * accepting our messages) can leave `relayClient.connect()`,
34 > * `waitForForwardedPort()`, `connectToForwardedPort()`, or the WebSocket `'open'`
35 > * event pending forever — which in turn hangs the renderer's
36 > * `_tunnelService.connect(...)` await, leaving the per-host `_pendingConnects`
37 > * flag set and effectively disabling auto-reconnect for the lifetime of the
38 > * shared process.
39 > */
40 > export const TUNNEL_STEP_TIMEOUT_MS = 30_000;
41 >
42 > export async function withTimeout<T>( tunnelAgentHostService.ts ×1
43 > op: () => Promise<T>,
44 > timeoutMs: number,
45 > stepName: string,
46 > ): Promise<T> {
47 > // Use raceTimeout so the timer is cleared in `finally` once `op` settles
48 > // (avoids stray timers across frequent reconnect attempts). The void-return
49 > // disambiguation is handled by the onTimeout callback flag below.
50 > let timedOut = false;
51 > const result = await raceTimeout(op(), timeoutMs, () => { timedOut = true; });
52 > if (timedOut) { tunnelAgentHostService.ts ×1
53 > throw new Error(`${LOG_PREFIX} ${stepName} timed out after ${timeoutMs}ms`); tunnelAgentHostService.ts ×1
54 > }
55 > return result as T; tunnelAgentHostService.ts ×1
56 > }
58 > /**
59 > * Derive a connection token from a tunnel ID using the same convention
60 > * as the VS Code CLI (see `get_connection_token` in cli/src/commands/tunnels.rs).
61 > */
62 function deriveConnectionToken(tunnelId: string): string {
63 const hash = createHash('sha256');
64 hash.update(tunnelId);
65 let result = hash.digest('base64url');
66 if (result.startsWith('-')) {
67 result = `a${result}`;
68 }
69 return result;
70 }
72 > /** State for a single active tunnel relay connection. */
73 > class TunnelConnection extends Disposable {
74 > private readonly _onDidClose = this._register(new Emitter<void>());
75 > readonly onDidClose = this._onDidClose.event;
76 >
77 > private _closed = false;
78 >
79 > constructor(
80 readonly connectionId: string,
81 readonly address: string,
82 readonly name: string,
83 readonly connectionToken: string,
84 private readonly _relay: { send: (data: string) => void; close: () => void },
85 private readonly _relayClient: { dispose(): void },
86 ) {
87 super();
88 }
90 > override dispose(): void {
91 if (!this._closed) {
92 this._closed = true;
93 this._relay.close();
94 this._relayClient.dispose();
95 this._onDidClose.fire();
96 }
97 super.dispose();
98 }
100 > relaySend(data: string): void {
101 this._relay.send(data);
102 }
104 >
105 > export class TunnelAgentHostMainService extends Disposable implements ITunnelAgentHostMainService {
106 > declare readonly _serviceBrand: undefined;
107 >
108 > private readonly _onDidRelayMessage = this._register(new Emitter<ITunnelRelayMessage>());
109 > readonly onDidRelayMessage: Event<ITunnelRelayMessage> = this._onDidRelayMessage.event;
110 >
111 > private readonly _onDidRelayClose = this._register(new Emitter<string>());
112 > readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;
113 >
114 > private readonly _connections = new Map<string, TunnelConnection>();
115 >
116 > constructor(
117 @ILogService private readonly _logService: ILogService,
118 ) {
119 super();
120 }
122 > async listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise<ITunnelInfo[]> {
123 const client = await this._createManagementClient(token, authProvider);
124 const results: ITunnelInfo[] = [];
125 const seen = new Set<string>();
126
127 try {
128 // Enumerate all tunnels with the vscode-server-launcher label
129 const tunnels = await client.listTunnels(undefined, undefined, {
130 labels: [TUNNEL_LAUNCHER_LABEL],
131 requireAllLabels: true,
132 includePorts: true,
133 tokenScopes: ['connect'],
134 });
135
136 for (const tunnel of tunnels) {
137 const info = this._parseTunnelInfo(tunnel);
138 if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) {
139 results.push(info);
140 seen.add(info.tunnelId);
141 }
142 }
143 } catch (err) {
144 this._logService.error(`${LOG_PREFIX} Failed to enumerate tunnels`, err);
145 }
146
147 // Look up additional tunnels by name
148 if (additionalTunnelNames) {
149 for (const tunnelName of additionalTunnelNames) {
150 try {
151 const [tunnel] = await client.listTunnels(undefined, undefined, {
152 labels: [tunnelName, TUNNEL_LAUNCHER_LABEL],
153 requireAllLabels: true,
154 includePorts: true,
155 tokenScopes: ['connect'],
156 limit: 1,
157 });
158 if (tunnel) {
159 const info = this._parseTunnelInfo(tunnel);
160 if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION && !seen.has(info.tunnelId)) {
161 results.push(info);
162 seen.add(info.tunnelId);
163 }
164 }
165 } catch (err) {
166 this._logService.warn(`${LOG_PREFIX} Failed to look up tunnel '${tunnelName}'`, err);
167 }
168 }
169 }
170
171 this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`);
172 return results;
173 }
175 > async connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise<ITunnelConnectResult> {
176 // Tear down any existing connection to this tunnel first.
177 // Each connect() call creates a fresh relay with its own protocol
178 // session, so the old one must be closed to avoid conflicts.
179 for (const [id, conn] of this._connections) {
180 if (conn.address === `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`) {
181 this._logService.info(`${LOG_PREFIX} Closing existing relay for tunnel ${tunnelId} before reconnecting`);
182 this._connections.delete(id);
183 conn.dispose();
184 break;
185 }
186 }
187
188 const client = await this._createManagementClient(token, authProvider);
189 const connectionId = generateUuid();
190 const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`;
191
192 this._logService.info(`${LOG_PREFIX} Connecting to tunnel ${tunnelId} in cluster ${clusterId}...`);
193
194 // Get the full tunnel with endpoints and access tokens
195 const tunnel: Tunnel = { tunnelId, clusterId };
196 const resolved = await client.getTunnel(tunnel, {
197 includePorts: true,
198 tokenScopes: ['connect'],
199 });
200
201 if (!resolved) {
202 throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`);
203 }
204
205 // Connect to the tunnel relay
206 const { TunnelRelayTunnelClient } = await import('@microsoft/dev-tunnels-connections');
207 const relayClient = new TunnelRelayTunnelClient(client);
208 relayClient.acceptLocalConnectionsForForwardedPorts = false;
209 if (resolved.endpoints) {
210 relayClient.endpoints = resolved.endpoints;
211 }
212
213 // Bound each SDK step. A silently dead network can leave any of these
214 // pending forever, which would hang the renderer's
215 // `_tunnelService.connect(...)` await and prevent auto-reconnect from
216 // re-arming until the app is restarted.
217 let portStream: NodeJS.ReadWriteStream;
218 try {
219 await withTimeout(() => relayClient.connect(resolved), TUNNEL_STEP_TIMEOUT_MS, 'tunnel relay connect');
220 this._logService.info(`${LOG_PREFIX} Tunnel relay connected, waiting for port ${TUNNEL_AGENT_HOST_PORT}...`);
221
222 // Wait for the agent host port to become available
223 await withTimeout(() => relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `wait for forwarded port ${TUNNEL_AGENT_HOST_PORT}`);
224
225 // Connect to the forwarded port — returns a Duplex stream
226 portStream = await withTimeout(() => relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `connect to forwarded port ${TUNNEL_AGENT_HOST_PORT}`);
227 this._logService.info(`${LOG_PREFIX} Connected to forwarded port ${TUNNEL_AGENT_HOST_PORT}`);
228 } catch (err) {
229 // Clean up the dev-tunnels relay client so we don't leak an
230 // orphan client when the SDK call hangs or fails.
231 try {
232 relayClient.dispose();
233 } catch {
234 // ignore — best-effort cleanup
235 }
236 throw err;
237 }
238
239 // Derive connection token from tunnel ID (matches CLI convention)
240 const connectionToken = deriveConnectionToken(tunnelId);
241
242 // Parse display name from tags
243 const tags = new TunnelTags(resolved.labels);
244 const name = tags.name || resolved.name || tunnelId;
245
246 // Create WebSocket over the port stream
247 let relay: { send: (data: string) => void; close: () => void };
248 try {
249 relay = await withTimeout(
250 () => this._createWebSocketRelay(portStream, connectionToken, connectionId),
251 TUNNEL_STEP_TIMEOUT_MS,
252 'WebSocket relay open',
253 );
254 } catch (err) {
255 try {
256 relayClient.dispose();
257 } catch {
258 // ignore
259 }
260 throw err;
261 }
262
263 const conn = new TunnelConnection(
264 connectionId,
265 address,
266 name,
267 connectionToken,
268 relay,
269 relayClient,
270 );
271
272 conn.onDidClose(() => {
273 this._connections.delete(connectionId);
274 this._onDidRelayClose.fire(connectionId);
275 });
276
277 this._connections.set(connectionId, conn);
278 return { connectionId, address, name, connectionToken };
279 }
281 > async relaySend(connectionId: string, message: string): Promise<void> {
282 const conn = this._connections.get(connectionId);
283 if (conn) {
284 conn.relaySend(message);
285 }
286 }
288 > async disconnect(connectionId: string): Promise<void> {
289 const conn = this._connections.get(connectionId);
290 if (conn) {
291 conn.dispose();
292 }
293 }
295 > private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise<TunnelManagementHttpClient> {
296 const mgmt = await import('@microsoft/dev-tunnels-management');
297 const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`;
298
299 return new mgmt.TunnelManagementHttpClient(
300 'vscode-sessions',
301 mgmt.ManagementApiVersions.Version20230927preview,
302 async () => authHeader,
303 );
304 }
306 > private _parseTunnelInfo(tunnel: Tunnel): ITunnelInfo | undefined {
307 const labels = tunnel.labels ?? [];
308 const tags = new TunnelTags(labels);
309
310 if (tags.protocolVersion < TUNNEL_MIN_PROTOCOL_VERSION) {
311 return undefined;
312 }
313
314 const tunnelId = tunnel.tunnelId;
315 const clusterId = tunnel.clusterId;
316 if (!tunnelId || !clusterId) {
317 return undefined;
318 }
319
320 const name = tags.name || tunnel.name || tunnelId;
321 const rawCount = tunnel.status?.hostConnectionCount;
322 const hostConnectionCount = typeof rawCount === 'number' ? rawCount : (rawCount?.current ?? 0);
323 return {
324 tunnelId,
325 clusterId,
326 name,
327 tags: labels,
328 protocolVersion: tags.protocolVersion,
329 hostConnectionCount,
330 };
331 }
333 > private async _createWebSocketRelay(
334 portStream: NodeJS.ReadWriteStream,
335 connectionToken: string,
336 connectionId: string,
337 ): Promise<{ send: (data: string) => void; close: () => void }> {
338 const WS = await import('ws');
339
340 return new Promise((resolve, reject) => {
341 // Construct WebSocket URL — the stream is already connected to the right port
342 let url = `ws://localhost:${TUNNEL_AGENT_HOST_PORT}`;
343 if (connectionToken) {
344 url += `?tkn=${encodeURIComponent(connectionToken)}`;
345 }
346
347 // Create WebSocket over the existing stream from the tunnel relay
348 const ws = new WS.WebSocket(url, {
349 createConnection: (() => portStream) as unknown as WebSocket.ClientOptions['createConnection'],
350 });
351
352 ws.on('open', () => {
353 this._logService.info(`${LOG_PREFIX} WebSocket relay connected to agent host via tunnel`);
354 resolve({
355 send: (data: string) => {
356 if (ws.readyState === ws.OPEN) {
357 ws.send(data);
358 }
359 },
360 close: () => ws.close(),
361 });
362 });
363
364 ws.on('message', (data: WebSocket.RawData) => {
365 let text: string;
366 if (Array.isArray(data)) {
367 text = Buffer.concat(data).toString();
368 } else if (data instanceof ArrayBuffer) {
369 text = Buffer.from(new Uint8Array(data)).toString();
370 } else {
371 text = data.toString();
372 }
373 this._onDidRelayMessage.fire({ connectionId, data: text });
374 });
375
376 ws.on('close', (code: number, reason: Buffer) => {
377 this._logService.info(`${LOG_PREFIX} WebSocket relay closed for connection ${connectionId}; code=${code}, reason=${reason?.toString() || '(empty)'}`);
378 const conn = this._connections.get(connectionId);
379 if (conn) {
380 conn.dispose();
381 }
382 });
383
384 ws.on('error', (wsErr: unknown) => {
385 this._logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`);
386 reject(wsErr);
387 });
388 });
389 }