From 665e82e14059c16c3353c9c395d2288cad13f325 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 8 Apr 2026 16:59:55 -0700 Subject: [PATCH 1/5] Fix isOnline() to fall back to HTTP HEAD when DNS fails behind proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In enterprise proxy environments, the client machine may not resolve external DNS directly — DNS resolution is handled by the proxy server. The existing DNS-only check in isOnline() would incorrectly report the machine as offline, causing unnecessary fallback to cached/offline installs. When DNS resolution fails and the axios client is available, isOnline() now attempts a lightweight HEAD request to www.microsoft.com through the auto-detected (or manually configured) proxy. This ensures connectivity is correctly detected in proxy environments while keeping DNS as the fast-path for non-proxy setups. Refactors GetProxyAgentIfNeeded by extracting proxy resolution into a standalone getProxyAgent method that does not depend on IAcquisitionWorkerContext. This allows isOnline and other callers without a full context to reuse the same proxy detection logic without duplication. Fixes #2594 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Utils/WebRequestWorkerSingleton.ts | 70 ++++++-- .../src/test/unit/WebRequestWorker.test.ts | 149 ++++++++++++++++++ vscode-dotnet-runtime-library/yarn.lock | 5 + 3 files changed, 212 insertions(+), 12 deletions(-) diff --git a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts index 42cdb55789..503a1c35f8 100644 --- a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts +++ b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts @@ -281,7 +281,7 @@ export class WebRequestWorkerSingleton // ... 100 ms is there as a default to prevent the dns resolver from throwing a runtime error if the user sets timeoutSeconds to 0. const dnsResolver = new dns.promises.Resolver({ timeout: expectedDNSResolutionTimeMs }); - const couldConnect = await dnsResolver.resolve(microsoftServerHostName).then(() => + const dnsOnline = await dnsResolver.resolve(microsoftServerHostName).then(() => { return true; }).catch((error: any) => @@ -290,7 +290,47 @@ export class WebRequestWorkerSingleton return false; }); - return couldConnect; + if (dnsOnline) + { + return true; + } + + // DNS failed — but in proxy environments, the proxy handles DNS resolution, so direct DNS lookups may fail + // even though HTTP connectivity works fine. Fall back to a lightweight HEAD request through the proxy-configured client. + if (this.client) + { + const httpFallbackTimeoutMs = Math.max(timeoutSec * 1000, 2000); + const proxyAgent = await this.getProxyAgent(undefined, eventStream); + + const headOptions: object = { + timeout: httpFallbackTimeoutMs, + cache: false, + validateStatus: () => true, // Any HTTP response means we're online, even 4xx/5xx + ...(proxyAgent !== null && { proxy: false }), + ...(proxyAgent !== null && { httpsAgent: proxyAgent }), + }; + + const headOnline = await this.client.head(`https://${microsoftServerHostName}`, headOptions) + .then(() => + { + return true; // Any response at all means we have connectivity + }) + .catch(() => + { + return false; + }); + + if (headOnline) + { + eventStream.post(new OfflineDetectionLogicTriggered(new EventCancellationError('DnsFailedButHttpSucceeded', + `DNS resolution failed but HTTP HEAD request succeeded. This may indicate a proxy is handling DNS.`), + `DNS failed but HTTP connectivity confirmed via HEAD request to ${microsoftServerHostName}.`)); + } + + return headOnline; + } + + return false; } /** * @@ -321,11 +361,22 @@ export class WebRequestWorkerSingleton } private async GetProxyAgentIfNeeded(ctx: IAcquisitionWorkerContext): Promise | null> + { + return this.getProxyAgent(ctx.proxyUrl, ctx.eventStream); + } + + /** + * Resolves a proxy agent from the manual proxy URL or auto-detected system proxy settings. + * Decoupled from IAcquisitionWorkerContext so it can be used by isOnline and other callers that don't have a full context. + */ + private async getProxyAgent(manualProxyUrl?: string, eventStream?: IEventStream): Promise | null> { try { + const hasManualProxy = manualProxyUrl ? manualProxyUrl !== '""' : false; + let discoveredProxy = ''; - if (!this.proxySettingConfiguredManually(ctx)) + if (!hasManualProxy) { const autoDetectProxies = await getProxySettings(); if (autoDetectProxies?.https) @@ -338,17 +389,17 @@ export class WebRequestWorkerSingleton } } - if (this.proxySettingConfiguredManually(ctx) || discoveredProxy) + if (hasManualProxy || discoveredProxy) { - const finalProxy = ctx?.proxyUrl && ctx?.proxyUrl !== '""' && ctx?.proxyUrl !== '' ? ctx.proxyUrl : discoveredProxy; - ctx.eventStream.post(new ProxyUsed(`Utilizing the Proxy : Manual ? ${ctx?.proxyUrl}, Automatic: ${discoveredProxy}, Decision : ${finalProxy}`)) + const finalProxy = manualProxyUrl && manualProxyUrl !== '""' && manualProxyUrl !== '' ? manualProxyUrl : discoveredProxy; + eventStream?.post(new ProxyUsed(`Utilizing the Proxy : Manual ? ${manualProxyUrl}, Automatic: ${discoveredProxy}, Decision : ${finalProxy}`)) const proxyAgent = new HttpsProxyAgent(finalProxy); return proxyAgent; } } catch (error: any) { - ctx.eventStream.post(new SuppressedAcquisitionError(error, `The proxy lookup failed, most likely due to limited registry access. Skipping automatic proxy lookup.`)); + eventStream?.post(new SuppressedAcquisitionError(error, `The proxy lookup failed, most likely due to limited registry access. Skipping automatic proxy lookup.`)); } return null; @@ -485,11 +536,6 @@ If you're on a proxy and disable registry access, you must set the proxy in our } } - private proxySettingConfiguredManually(ctx: IAcquisitionWorkerContext): boolean - { - return ctx?.proxyUrl ? ctx?.proxyUrl !== '""' : false; - } - private timeoutMsFromCtx(ctx: IAcquisitionWorkerContext): number { return ctx?.timeoutSeconds * 1000; diff --git a/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts b/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts index 6b8a265444..2eae98d22d 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts @@ -5,6 +5,9 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; +import * as http from 'http'; +import * as https from 'https'; +import * as dns from 'dns'; import * as path from 'path'; import { DotnetCoreAcquisitionWorker } from '../../Acquisition/DotnetCoreAcquisitionWorker'; import { IInstallScriptAcquisitionWorker } from '../../Acquisition/IInstallScriptAcquisitionWorker'; @@ -12,6 +15,7 @@ import { DotnetFallbackInstallScriptUsed, DotnetInstallScriptAcquisitionError, + OfflineDetectionLogicTriggered, WebRequestTime, } from '../../EventStream/EventStreamEvents'; import @@ -127,3 +131,148 @@ suite('WebRequestWorker Unit Tests', function () }); }); +/** + * Helper that intercepts all outbound HTTP/HTTPS requests and DNS lookups to simulate a fully offline machine. + * Blocks at the http/https.request level which is what axios uses internally. + * Returns a restore function that undoes the patching. + */ +function simulateOffline(): () => void +{ + const originalHttpsRequest = https.request; + const originalHttpRequest = http.request; + const originalDnsResolve = dns.promises.Resolver.prototype.resolve; + + // Block all HTTPS requests + (https as any).request = function (...args: any[]) + { + const req = new http.ClientRequest('https://localhost:1'); + process.nextTick(() => req.destroy(new Error('simulateOffline: HTTPS request blocked'))); + return req; + }; + + // Block all HTTP requests + (http as any).request = function (...args: any[]) + { + const req = new http.ClientRequest('http://localhost:1'); + process.nextTick(() => req.destroy(new Error('simulateOffline: HTTP request blocked'))); + return req; + }; + + // Block DNS resolution + dns.promises.Resolver.prototype.resolve = function () + { + return Promise.reject(new Error('simulateOffline: DNS blocked')); + } as any; + + return () => + { + (https as any).request = originalHttpsRequest; + (http as any).request = originalHttpRequest; + dns.promises.Resolver.prototype.resolve = originalDnsResolve; + }; +} + +/** + * Helper that blocks only DNS resolution but allows TCP/TLS connections through. + * Simulates a proxy environment where DNS doesn't resolve locally but HTTP works. + */ +function simulateDnsOnlyFailure(): () => void +{ + const originalDnsResolve = dns.promises.Resolver.prototype.resolve; + + dns.promises.Resolver.prototype.resolve = function () + { + return Promise.reject(new Error('simulateDnsOnlyFailure: DNS blocked')); + } as any; + + return () => + { + dns.promises.Resolver.prototype.resolve = originalDnsResolve; + }; +} + +suite('isOnline Connectivity Detection Tests', function () +{ + this.afterEach(async () => + { + // Reset the singleton so each test gets a fresh instance + (WebRequestWorkerSingleton as any).instance = undefined; + }); + + test('isOnline returns false when fully offline (DNS + HTTP both blocked)', async () => + { + const eventStream = new MockEventStream(); + + // Reset singleton so the new instance is created while network is blocked + (WebRequestWorkerSingleton as any).instance = undefined; + + const restoreNetwork = simulateOffline(); + try + { + const result = await WebRequestWorkerSingleton.getInstance().isOnline(5, eventStream); + assert.isFalse(result, 'Should report offline when all network is blocked'); + + const offlineEvent = eventStream.events.find(event => event instanceof OfflineDetectionLogicTriggered); + assert.exists(offlineEvent, 'Should log an offline detection event for the DNS failure'); + } + finally + { + restoreNetwork(); + } + }).timeout(15000); + + test('isOnline returns true when DNS fails but HTTP succeeds (proxy environment)', async () => + { + const eventStream = new MockEventStream(); + const restoreNetwork = simulateDnsOnlyFailure(); + try + { + const result = await WebRequestWorkerSingleton.getInstance().isOnline(5, eventStream); + assert.isTrue(result, 'Should report online when DNS fails but HTTP HEAD succeeds'); + + const dnsFailEvent = eventStream.events.find(event => + event instanceof OfflineDetectionLogicTriggered && + event.supplementalMessage.includes('DNS resolution failed')); + assert.exists(dnsFailEvent, 'Should log a DNS failure event'); + + const httpSuccessEvent = eventStream.events.find(event => + event instanceof OfflineDetectionLogicTriggered && + event.supplementalMessage.includes('HTTP connectivity confirmed')); + assert.exists(httpSuccessEvent, 'Should log that HTTP fallback succeeded'); + } + finally + { + restoreNetwork(); + } + }).timeout(15000); + + test('isOnline returns true when DNS succeeds (normal environment)', async () => + { + const eventStream = new MockEventStream(); + const result = await WebRequestWorkerSingleton.getInstance().isOnline(5, eventStream); + assert.isTrue(result, 'Should report online when DNS resolves successfully'); + }).timeout(15000); + + test('isOnline returns false when DOTNET_INSTALL_TOOL_OFFLINE env var is set', async () => + { + const eventStream = new MockEventStream(); + const originalEnv = process.env.DOTNET_INSTALL_TOOL_OFFLINE; + process.env.DOTNET_INSTALL_TOOL_OFFLINE = '1'; + try + { + const result = await WebRequestWorkerSingleton.getInstance().isOnline(5, eventStream); + assert.isFalse(result, 'Should report offline when DOTNET_INSTALL_TOOL_OFFLINE=1'); + } + finally + { + if (originalEnv === undefined) + { + delete process.env.DOTNET_INSTALL_TOOL_OFFLINE; + } + else + { + process.env.DOTNET_INSTALL_TOOL_OFFLINE = originalEnv; + } + } + }).timeout(5000); +}); diff --git a/vscode-dotnet-runtime-library/yarn.lock b/vscode-dotnet-runtime-library/yarn.lock index 83b44bd9ef..eec4ed6d28 100644 --- a/vscode-dotnet-runtime-library/yarn.lock +++ b/vscode-dotnet-runtime-library/yarn.lock @@ -637,6 +637,11 @@ fs.realpath@^1.0.0: resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@^2.3.3: + version "2.3.3" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/fsevents/-/fsevents-2.3.3.tgz" + integrity sha1-ysZAd4XQNnWipeGlMFxpezR9kNY= + function-bind@^1.1.2: version "1.1.2" resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/function-bind/-/function-bind-1.1.2.tgz" From 06211f0b2991441e335fd6709ba21581893ae094 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 9 Apr 2026 16:18:54 -0700 Subject: [PATCH 2/5] Restore proxySettingConfiguredManually for readability Reintroduce the helper method with a simplified signature that takes proxyUrl directly instead of the full IAcquisitionWorkerContext, keeping the code readable while maintaining the decoupled getProxyAgent design. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Utils/WebRequestWorkerSingleton.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts index 1f7e66c0e0..336863ef4e 100644 --- a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts +++ b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts @@ -373,7 +373,7 @@ export class WebRequestWorkerSingleton { try { - const hasManualProxy = manualProxyUrl ? manualProxyUrl !== '""' : false; + const hasManualProxy = this.proxySettingConfiguredManually(manualProxyUrl); let discoveredProxy = ''; if (!hasManualProxy) @@ -536,6 +536,11 @@ If you're on a proxy and disable registry access, you must set the proxy in our } } + private proxySettingConfiguredManually(proxyUrl?: string): boolean + { + return proxyUrl ? proxyUrl !== '""' : false; + } + private timeoutMsFromCtx(ctx: IAcquisitionWorkerContext): number { return ctx?.timeoutSeconds * 1000; From 8420306fdc6fb89b8a4be2f228f647159fe992fb Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 9 Apr 2026 16:28:32 -0700 Subject: [PATCH 3/5] Pass proxyUrl through isOnline and consolidate proxy validation Add optional proxyUrl parameter to isOnline() so callers with access to the VS Code proxy setting can pass it through for the HTTP HEAD fallback. All callsites with a workerContext now forward ctx.proxyUrl; the one callsite without (LocalInstallUpdateService) still falls back to auto-detection. Consolidate the empty-string check into proxySettingConfiguredManually so the duplicate validation in getProxyAgent is no longer needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vscode-dotnet-runtime-extension/src/extension.ts | 6 +++--- .../src/Acquisition/AcquisitionInvoker.ts | 2 +- .../src/Utils/WebRequestWorkerSingleton.ts | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index 6ec2062f84..dc0cc60ef4 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -266,7 +266,7 @@ export function activate(vsCodeContext: vscode.ExtensionContext, extensionContex commandContext.forceUpdate = true; } - const isOffline = !(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream)); + const isOffline = !(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream, workerContext.proxyUrl)); if (!commandContext.forceUpdate || isOffline) { // 3.0 Breaking Change: Don't always return latest .NET runtime by default @@ -971,7 +971,7 @@ ${JSON.stringify(commandContext)}`)); async function getExistingInstallIfOffline(worker: DotnetCoreAcquisitionWorker, workerContext: IAcquisitionWorkerContext): Promise { - const isOffline = !(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream)); + const isOffline = !(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream, workerContext.proxyUrl)); if (isOffline) { return getExistingInstallOffline(worker, workerContext); @@ -989,7 +989,7 @@ ${JSON.stringify(commandContext)}`)); } else { - if (!(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream))) + if (!(await WebRequestWorkerSingleton.getInstance().isOnline(timeoutValue ?? defaultTimeoutValue, globalEventStream, workerContext.proxyUrl))) { globalEventStream.post(new DotnetOfflineWarning(`It looks like you may be offline (can you connect to www.microsoft.com?) and have no compatible installations of .NET ${workerContext.acquisitionContext.version} for ${workerContext.acquisitionContext.requestingExtensionId ?? 'user'}. Installation will timeout in ${timeoutValue} seconds.`)) diff --git a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts index 86fc03d96f..29431ed9d1 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts @@ -127,7 +127,7 @@ If you cannot change this flag, try setting a custom existingDotnetPath via the } if (error) { - if (!(await WebRequestWorkerSingleton.getInstance().isOnline(this.workerContext.timeoutSeconds, this.eventStream))) + if (!(await WebRequestWorkerSingleton.getInstance().isOnline(this.workerContext.timeoutSeconds, this.eventStream, this.workerContext.proxyUrl))) { const offlineError = new EventBasedError('DotnetOfflineFailure', 'No internet connection detected: Cannot install .NET'); this.eventStream.post(new DotnetOfflineFailure(offlineError, install)); diff --git a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts index 336863ef4e..ecce5fd580 100644 --- a/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts +++ b/vscode-dotnet-runtime-library/src/Utils/WebRequestWorkerSingleton.ts @@ -158,7 +158,7 @@ export class WebRequestWorkerSingleton { timeoutCancelTokenHook.abort(); ctx.eventStream.post(new WebRequestTime(`Timer for request:`, String(this.timeoutMsFromCtx(ctx)), 'false', url, '777')); // 777 for custom abort status. arbitrary - if (!(await this.isOnline(ctx.timeoutSeconds, ctx.eventStream))) + if (!(await this.isOnline(ctx.timeoutSeconds, ctx.eventStream, ctx.proxyUrl))) { const offlineError = new EventBasedError('DotnetOfflineFailure', 'No internet connection detected: Cannot install .NET'); ctx.eventStream.post(new DotnetOfflineFailure(offlineError, null)); @@ -269,7 +269,7 @@ export class WebRequestWorkerSingleton } } - public async isOnline(timeoutSec: number, eventStream: IEventStream): Promise + public async isOnline(timeoutSec: number, eventStream: IEventStream, proxyUrl?: string): Promise { if (process.env.DOTNET_INSTALL_TOOL_OFFLINE === '1') { @@ -300,7 +300,7 @@ export class WebRequestWorkerSingleton if (this.client) { const httpFallbackTimeoutMs = Math.max(timeoutSec * 1000, 2000); - const proxyAgent = await this.getProxyAgent(undefined, eventStream); + const proxyAgent = await this.getProxyAgent(proxyUrl, eventStream); const headOptions: object = { timeout: httpFallbackTimeoutMs, @@ -391,7 +391,7 @@ export class WebRequestWorkerSingleton if (hasManualProxy || discoveredProxy) { - const finalProxy = manualProxyUrl && manualProxyUrl !== '""' && manualProxyUrl !== '' ? manualProxyUrl : discoveredProxy; + const finalProxy = hasManualProxy ? manualProxyUrl! : discoveredProxy; eventStream?.post(new ProxyUsed(`Utilizing the Proxy : Manual ? ${manualProxyUrl}, Automatic: ${discoveredProxy}, Decision : ${finalProxy}`)) const proxyAgent = new HttpsProxyAgent(finalProxy); return proxyAgent; @@ -538,7 +538,7 @@ If you're on a proxy and disable registry access, you must set the proxy in our private proxySettingConfiguredManually(proxyUrl?: string): boolean { - return proxyUrl ? proxyUrl !== '""' : false; + return proxyUrl ? proxyUrl !== '""' && proxyUrl !== '' : false; } private timeoutMsFromCtx(ctx: IAcquisitionWorkerContext): number From 2464c43da86329ff3642a93ea667f7ce06c3707d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 9 Apr 2026 16:41:29 -0700 Subject: [PATCH 4/5] Use realistic error codes in offline test mocks Updated simulateOffline and simulateDnsOnlyFailure test helpers to emit error codes matching real observed behavior when a Windows firewall blocks node.exe outbound: - DNS: ETIMEOUT (queryA ETIMEOUT www.microsoft.com) - HTTP/HTTPS: EACCES with errno -4092, syscall connect Verified by blocking node.exe via Windows Firewall and capturing actual error shapes from dns.promises.Resolver, Axios, and axios-cache-interceptor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/unit/WebRequestWorker.test.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts b/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts index 1769b2f378..6a9604a4ca 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts @@ -143,7 +143,8 @@ suite('WebRequestWorker Unit Tests', function () /** * Helper that intercepts all outbound HTTP/HTTPS requests and DNS lookups to simulate a fully offline machine. - * Blocks at the http/https.request level which is what axios uses internally. + * Error codes match real observed behavior when a firewall blocks node.exe outbound traffic: + * DNS: ETIMEOUT, Axios: EACCES with no response. * Returns a restore function that undoes the patching. */ function simulateOffline(): () => void @@ -152,11 +153,15 @@ function simulateOffline(): () => void const originalHttpRequest = http.request; const originalDnsResolve = dns.promises.Resolver.prototype.resolve; - // Block all HTTPS requests + // Block all HTTPS requests — emit EACCES matching real firewall behavior (https as any).request = function (...args: any[]) { const req = new http.ClientRequest('https://localhost:1'); - process.nextTick(() => req.destroy(new Error('simulateOffline: HTTPS request blocked'))); + const err: NodeJS.ErrnoException = new Error('connect EACCES'); + err.code = 'EACCES'; + err.errno = -4092; + err.syscall = 'connect'; + process.nextTick(() => req.destroy(err)); return req; }; @@ -164,14 +169,20 @@ function simulateOffline(): () => void (http as any).request = function (...args: any[]) { const req = new http.ClientRequest('http://localhost:1'); - process.nextTick(() => req.destroy(new Error('simulateOffline: HTTP request blocked'))); + const err: NodeJS.ErrnoException = new Error('connect EACCES'); + err.code = 'EACCES'; + err.errno = -4092; + err.syscall = 'connect'; + process.nextTick(() => req.destroy(err)); return req; }; - // Block DNS resolution + // Block DNS resolution — emit ETIMEOUT matching real offline behavior dns.promises.Resolver.prototype.resolve = function () { - return Promise.reject(new Error('simulateOffline: DNS blocked')); + const err: NodeJS.ErrnoException = new Error('queryA ETIMEOUT www.microsoft.com'); + err.code = 'ETIMEOUT'; + return Promise.reject(err); } as any; return () => @@ -192,7 +203,9 @@ function simulateDnsOnlyFailure(): () => void dns.promises.Resolver.prototype.resolve = function () { - return Promise.reject(new Error('simulateDnsOnlyFailure: DNS blocked')); + const err: NodeJS.ErrnoException = new Error('queryA ETIMEOUT www.microsoft.com'); + err.code = 'ETIMEOUT'; + return Promise.reject(err); } as any; return () => From 9bf769f1ea59f944ec9a19f643790cb438b49ebf Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 9 Apr 2026 16:57:18 -0700 Subject: [PATCH 5/5] Add e2e proxy tests: isOnline via proxy + full acquisition through proxy - Add startLocalProxy() helper: stdlib-only HTTP CONNECT proxy (zero deps) using net + http modules, supports HTTPS tunneling for HttpsProxyAgent - Add test: isOnline returns true via proxy when DNS is blocked (simulates enterprise proxy environment where client DNS fails) - Add test: getCachedData succeeds through proxy when DNS is blocked (exercises full getAxiosOptions -> GetProxyAgentIfNeeded -> HttpsProxyAgent chain) - Both tests use simulateDnsOnlyFailure() + local proxy with proper cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/unit/WebRequestWorker.test.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts b/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts index 6a9604a4ca..293c14f408 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/WebRequestWorker.test.ts @@ -299,3 +299,113 @@ suite('isOnline Connectivity Detection Tests', function () } }).timeout(5000); }); + +import * as net from 'net'; + +/** + * Starts a local HTTP CONNECT proxy server on a random port. + * This proxy handles HTTPS tunneling (CONNECT method) by piping a raw TCP connection + * between the client and the target host. The proxy itself speaks plain HTTP — the client + * sends "CONNECT host:443 HTTP/1.1", the proxy opens a TCP socket to host:443, then pipes + * bytes bidirectionally. TLS is negotiated end-to-end between the client and target server + * (the proxy never sees decrypted traffic). + * + * @returns { server, port, close } — the server, its port, and a cleanup function. + */ +async function startLocalProxy(): Promise<{ server: http.Server; port: number; close: () => Promise }> +{ + const server = http.createServer((_req, res) => + { + // Reject non-CONNECT requests (we only support tunneling) + res.writeHead(405); + res.end('Only CONNECT is supported'); + }); + + server.on('connect', (req: http.IncomingMessage, clientSocket: net.Socket, head: Buffer) => + { + const [host, portStr] = (req.url ?? '').split(':'); + const port = parseInt(portStr, 10) || 443; + + const targetSocket = net.connect(port, host, () => + { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + targetSocket.write(head); + targetSocket.pipe(clientSocket); + clientSocket.pipe(targetSocket); + }); + + targetSocket.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => targetSocket.destroy()); + }); + + return new Promise((resolve) => + { + server.listen(0, '127.0.0.1', () => + { + const addr = server.address() as net.AddressInfo; + resolve({ + server, + port: addr.port, + close: () => new Promise((res) => server.close(() => res())) + }); + }); + }); +} + +suite('Proxy-based Connectivity Tests', function () +{ + let proxy: { server: http.Server; port: number; close: () => Promise }; + let restoreDns: (() => void) | null = null; + + this.beforeEach(async () => + { + proxy = await startLocalProxy(); + }); + + this.afterEach(async () => + { + if (restoreDns) + { + restoreDns(); + restoreDns = null; + } + (WebRequestWorkerSingleton as any).instance = undefined; + await proxy.close(); + }); + + test('isOnline returns true via proxy when DNS is blocked', async () => + { + const eventStream = new MockEventStream(); + const proxyUrl = `http://127.0.0.1:${proxy.port}`; + + // Block DNS — simulates enterprise environment where client can't resolve external DNS + restoreDns = simulateDnsOnlyFailure(); + + const result = await WebRequestWorkerSingleton.getInstance().isOnline(10, eventStream, proxyUrl); + assert.isTrue(result, 'Should report online when proxy handles the connection despite DNS failure'); + + const httpSuccess = eventStream.events.find(event => + event instanceof OfflineDetectionLogicTriggered && + event.supplementalMessage.includes('HTTP connectivity confirmed')); + assert.exists(httpSuccess, 'Should log that the HTTP fallback via proxy succeeded'); + }).timeout(15000); + + test('Web request succeeds through proxy when DNS is blocked', async () => + { + const eventStream = new MockEventStream(); + const proxyUrl = `http://127.0.0.1:${proxy.port}`; + const ctx = getMockAcquisitionContext('runtime', '', 30, eventStream); + ctx.proxyUrl = proxyUrl; + + // Block DNS + restoreDns = simulateDnsOnlyFailure(); + + // Make a real web request through the proxy — this exercises the full getAxiosOptions → GetProxyAgentIfNeeded → HttpsProxyAgent chain + const result = await WebRequestWorkerSingleton.getInstance().getCachedData(staticWebsiteUrl, ctx); + assert.exists(result, 'Should receive data through the proxy even with DNS blocked'); + // The response is a JSON object (parsed by axios). Verify it contains expected structure. + const resultStr = typeof result === 'string' ? result : JSON.stringify(result); + assert.isTrue(resultStr.length > 0, 'Response should contain data'); + assert.include(resultStr, 'channel-version', 'Response should contain expected release metadata'); + }).timeout(30000); +});