src/vs/platform/agentHost/node/shared/loopbackProxyServer.ts

332 LOC · 321 covered · 11 uncovered · 54 ranges · 1446 concepts · 18 introducers · 722 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.

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 > /*--------------------------------------------------------------------------------------------- loopbackProxyServer.ts ×12
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 * as http from 'http';
7 > import { AddressInfo } from 'net';
8 > import { IDisposable } from '../../../../base/common/lifecycle.js';
9 > import { ILogService } from '../../../log/common/log.js';
10 >
11 > // #region Public types
12 >
13 > /**
14 > * Per-request bookkeeping shared by every loopback proxy. `clientGone`
15 > * distinguishes a client-driven disconnect (socket already closed — write
16 > * nothing) from a service-driven `dispose()` (socket still open —
17 > * `res.destroy()` to unblock the client) when the abort signal fires.
18 > */
19 > export interface IProxyInFlight {
20 > readonly ac: AbortController;
21 > readonly res: http.ServerResponse;
22 > clientGone: boolean;
23 > }
24 >
25 > /**
26 > * The shared, refcounted runtime exposed to subclasses while servicing
27 > * requests and minting handles. `state` is the subclass-owned mutable
28 > * payload (e.g. the current GitHub token) created once per bind by
29 > * {@link LoopbackProxyServer.createState} from the seed supplied to the
30 > * `acquire()` call that triggered the bind.
31 > */
32 > export interface ILoopbackProxyRuntime<TState> {
33 > /** e.g. `http://127.0.0.1:54321` — no trailing slash. */
34 > readonly baseUrl: string;
35 > /** 256-bit hex string minted for this bind. */
36 > readonly nonce: string;
37 > /** In-flight requests; aborted on teardown. */
38 > readonly inFlight: Set<IProxyInFlight>;
39 > /** Subclass-owned mutable per-bind state. */
40 > readonly state: TState;
41 > }
42 >
43 > /**
44 > * Minimal handle every loopback proxy hands back from `start()`. Subclasses
45 > * are free to widen this with extra members (e.g. `setToken`,
46 > * `providerBaseUrl`).
47 > *
48 > * **Subprocess ownership invariant.** Callers that hand `baseUrl` / `nonce`
49 > * to a subprocess MUST kill that subprocess before calling `dispose()` —
50 > * after the last handle is disposed the proxy may rebind on a different port
51 > * and the subprocess would silently lose its endpoint.
52 > */
53 > export interface ILoopbackProxyHandle extends IDisposable {
54 > /** e.g. `http://127.0.0.1:54321` — no trailing slash. */
55 > readonly baseUrl: string;
56 > /** 256-bit hex string. */
57 > readonly nonce: string;
58 > }
59 >
60 > // #endregion
61 >
62 > // #region Internal state
63 >
64 > interface IInternalRuntime<TState> extends ILoopbackProxyRuntime<TState> {
65 > readonly server: http.Server;
66 > refcount: number;
67 > }
68 >
69 > /**
70 > * Build the 256-bit hex nonce embedded in the proxy Bearer token. Web Crypto
71 > * is available in Node 18+.
72 > */
73 > function generateNonce(): string { loopbackProxyServer.ts ×8
74 > const bytes = new Uint8Array(32);
75 > crypto.getRandomValues(bytes);
76 > let out = '';
77 > for (let i = 0; i < bytes.length; i++) {
78 > out += bytes[i].toString(16).padStart(2, '0');
79 > }
80 > return out;
81 > }
83 > // #endregion
84 >
85 > /**
86 > * Reads the full body of an inbound request as a UTF-8 string.
87 > */
88 > export function readProxyRequestBody(req: http.IncomingMessage): Promise<string> {
89 > return new Promise((resolve, reject) => { loopbackProxyServer.ts ×1
90 > const chunks: Buffer[] = [];
91 > req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
92 > req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
93 > req.on('error', reject);
94 > });
95 > }
97 > /**
98 > * Reusable base for the agent-host loopback HTTP proxies. Owns the
99 > * full server lifecycle — lazy bind on `127.0.0.1`, nonce minting,
100 > * refcounted handles, in-flight tracking, and teardown — so each concrete
101 > * proxy only has to implement request routing (`handleRequest`) and the
102 > * shape of its `state` (`createState`).
103 > *
104 > * `TState` is the subclass-owned per-bind mutable state; `TSeed` is the
105 > * value each `acquire()` caller threads into `createState()` so the state
106 > * is born valid (e.g. with a real GitHub token rather than a placeholder).
107 > * It defaults to `void` for proxies whose state needs no seed.
108 > *
109 > * Lifecycle: the first `start()` binds a single shared server; concurrent
110 > * `start()` calls share that bind. Each handle holds a refcount; when the
111 > * last one is disposed (or `dispose()` is called explicitly) the listener
112 > * closes, in-flight requests are aborted, and the next `start()` rebinds
113 > * with a fresh port and nonce.
114 > */
115 > export abstract class LoopbackProxyServer<TState, TSeed = void> {
116 >
117 > private _runtime: IInternalRuntime<TState> | undefined;
118 > private _starting: Promise<IInternalRuntime<TState>> | undefined;
119 > private _disposed = false;
120 >
121 > constructor(
122 > /** Human-readable name used in log lines and error messages. */ loopbackProxyServer.ts ×8
123 > protected readonly name: string,
124 > protected readonly _logService: ILogService,
125 > ) { }
127 > protected get isDisposed(): boolean {
128 return this._disposed;
129 }
131 > /**
132 > * Build the subclass-owned mutable state object stored on the runtime.
133 > * Called exactly once per bind, before any request can be dispatched,
134 > * with the `seed` from the `acquire()` call that won the bind race so
135 > * the state starts out valid instead of holding a placeholder.
136 > */
137 > protected abstract createState(seed: TSeed): TState;
138 >
139 > /**
140 > * Route + service an authenticated inbound request. Invoked for every
141 > * request; any throw is caught by the base and turned into a 500.
142 > */
143 > protected abstract handleRequest(
144 > req: http.IncomingMessage,
145 > res: http.ServerResponse,
146 > runtime: ILoopbackProxyRuntime<TState>,
147 > ): Promise<void>;
148 >
149 > /**
150 > * Write the fallback "internal proxy error" response used when
151 > * {@link handleRequest} throws before sending headers. Subclasses may
152 > * override to match their wire format; the default emits a generic
153 > * JSON error envelope.
154 > */
155 > protected writeInternalError(res: http.ServerResponse): void {
156 > res.writeHead(500, { 'Content-Type': 'application/json' }); loopbackProxyServer.ts ×1
157 > res.end(JSON.stringify({ error: { type: 'api_error', message: 'Internal proxy error' } }));
158 > }
160 > /**
161 > * Acquire a refcounted lease on the shared runtime, binding the server
162 > * if it isn't running yet. Subclasses build their public handle around
163 > * the returned `runtime` and wire its `dispose()` to `release`.
164 > *
165 > * `seed` is forwarded to {@link createState} when this call triggers the
166 > * bind; for callers that join an existing bind it is ignored (the state
167 > * already exists), so they must apply their own value to `runtime.state`
168 > * afterwards if they need last-writer-wins semantics.
169 > *
170 > * Throws if the service has been disposed (including if `dispose()`
171 > * raced the bind).
172 > */
173 > protected async acquire(seed: TSeed): Promise<{ runtime: ILoopbackProxyRuntime<TState>; release: () => void }> {
174 > if (this._disposed) { loopbackProxyServer.ts ×8
175 > throw new Error(`${this.name} has been disposed`); loopbackProxyServer.ts ×1
176 > }
177 > const runtime = await this._ensureRuntime(seed); loopbackProxyServer.ts ×8
178 > // Re-check after the await: dispose() may have run while loopbackProxyServer.ts ×9
179 > // _ensureRuntime was awaiting the bind, in which case the runtime
180 > // we received is already torn down — but a fresh start() in between
181 > // is also possible, so verify the active runtime hasn't moved.
182 > if (this._disposed || this._runtime !== runtime) { loopbackProxyServer.ts ×8
183 throw new Error(`${this.name} has been disposed`);
184 }
185 > runtime.refcount++; loopbackProxyServer.ts ×9
186 >
187 > let released = false;
188 > const release = () => {
189 > if (released) {
191 > }
192 > released = true; loopbackProxyServer.ts ×9
193 > this._releaseHandle(runtime);
194 > };
195 > return { runtime, release };
198 > dispose(): void {
199 > if (this._disposed) { loopbackProxyServer.ts ×8
201 > }
202 > this._disposed = true; loopbackProxyServer.ts ×8
203 > this._teardownRuntime();
204 > }
206 > /**
207 > * Returns the shared runtime, binding a new server if there isn't one
208 > * yet. Concurrent callers share the same in-flight bind via
209 > * {@link _starting}; this prevents two listeners from being created when
210 > * {@link acquire} is invoked twice before the first bind resolves.
211 > *
212 > * If {@link dispose} runs while the bind is in flight, the just-bound
213 > * server is torn down here and the awaiting caller sees a rejected
214 > * promise.
215 > */
216 > private _ensureRuntime(seed: TSeed): Promise<IInternalRuntime<TState>> {
217 > if (this._runtime) { loopbackProxyServer.ts ×8
218 > return Promise.resolve(this._runtime); loopbackProxyServer.ts ×1
219 > }
220 > if (!this._starting) { loopbackProxyServer.ts ×8
221 > this._starting = (async () => {
222 > try {
223 > const rt = await this._startServer(seed);
224 > if (this._disposed) {
225 > // dispose() ran while we were binding — the teardown loopbackProxyServer.ts ×1
226 > // noop'd because _runtime was still undefined, so
227 > // close what we just created.
228 > rt.server.closeAllConnections();
229 > rt.server.close();
230 > throw new Error(`${this.name} has been disposed`);
231 > }
232 > this._runtime = rt; loopbackProxyServer.ts ×9
233 > return rt;
234 > } finally { loopbackProxyServer.ts ×8
235 > this._starting = undefined;
236 > }
237 > })();
238 > }
239 > return this._starting;
240 > }
242 > private _releaseHandle(runtime: IInternalRuntime<TState>): void {
243 > // If `dispose()` (or a later bind) already replaced the runtime, the loopbackProxyServer.ts ×9
244 > // handle's refcount no longer applies.
245 > if (this._runtime !== runtime) {
247 > }
248 > runtime.refcount--; loopbackProxyServer.ts ×1
249 > if (runtime.refcount === 0) {
250 > this._teardownRuntime();
251 > }
254 > private _teardownRuntime(): void {
255 > const runtime = this._runtime; loopbackProxyServer.ts ×8
256 > if (!runtime) {
258 > }
259 > this._runtime = undefined; loopbackProxyServer.ts ×9
260 > // Abort in-flight requests so their catch handlers run and destroy
261 > // still-open responses; closeAllConnections() then frees the
262 > // listening socket immediately.
263 > for (const entry of runtime.inFlight) {
264 > entry.ac.abort(); loopbackProxyServer.ts ×1
265 > }
266 > runtime.server.closeAllConnections(); loopbackProxyServer.ts ×9
267 > runtime.server.close(err => {
268 > if (err) {
269 this._logService.warn(`[${this.name}] server.close error: ${err.message}`);
270 }
274 > private async _startServer(seed: TSeed): Promise<IInternalRuntime<TState>> {
275 > const nonce = generateNonce(); loopbackProxyServer.ts ×8
276 > const inFlight = new Set<IProxyInFlight>();
277 > const httpModule = await import('http');
278 > const server = httpModule.createServer();
279 >
280 > await new Promise<void>((resolve, reject) => {
281 > const onError = (err: Error) => reject(err);
282 > server.once('error', onError);
283 > server.listen(0, '127.0.0.1', () => {
284 > server.removeListener('error', onError);
285 > resolve();
286 > });
287 > });
288 >
289 > const address = server.address();
290 > if (!address || typeof address === 'string') {
291 server.close();
292 throw new Error(`${this.name} failed to bind: unexpected address ${String(address)}`);
293 }
294 > const baseUrl = `http://127.0.0.1:${(address as AddressInfo).port}`; loopbackProxyServer.ts ×8
295 > this._logService.info(`[${this.name}] listening on ${baseUrl}`);
296 >
297 > const runtime: IInternalRuntime<TState> = {
298 > server,
299 > baseUrl,
300 > nonce,
301 > inFlight,
302 > refcount: 0,
303 > state: this.createState(seed),
304 > };
305 >
306 > // Attach the request handler only after `runtime` is fully built.
307 > // Node's single-threaded event loop guarantees no `request` event can
308 > // be parsed and dispatched between `listen` resolving and this
309 > // synchronous registration, so the handler can safely close over
310 > // `runtime` as a `const`.
311 > server.on('request', (req, res) => {
312 > this.handleRequest(req, res, runtime).catch(err => { loopbackProxyServer.ts ×2
313 > // Last-resort safety net. Concrete proxies are expected to loopbackProxyServer.ts ×2
314 > // handle their own throw paths.
315 > this._logService.error(`[${this.name}] unhandled request error: ${err instanceof Error ? err.message : String(err)}`);
316 > if (!res.headersSent) {
318 > this.writeInternalError(res);
319 > } catch {
320 // nothing else we can do
321 }
322 > } else if (!res.writableEnded) { loopbackProxyServer.ts ×2
324 > res.end();
325 > } catch { /* ignore */ }
326 > }
329 >
330 > return runtime;
331 > }