From 4da487b23386448d41153ac7cd8a94ef1367af8c Mon Sep 17 00:00:00 2001 From: Goga Koreli <12997449+gogakoreli@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:55:35 -0700 Subject: [PATCH 1/2] Extension host: enable native source maps for development hosts Enable Node's source-map support before importing the extension host. Keep the error-attribution walk and delegate stack formatting to the previous formatter when native source maps are enabled. Preserve the existing output for source-map-disabled hosts and custom formatter wrapping. Add isolated subprocess tests for real mapped frames, runtime-error and telemetry attribution, custom formatters, runtime toggling, and bootstrap opt-in behavior. Related to microsoft/vscode#145473 and #238469. X-AI-Prompt: Add dev-host-only native source maps with regression tests and raise a VS Code PR. X-AI-Tool: OpenAI coding assistant --- src/bootstrap-fork.ts | 6 + .../workbench/api/common/extensionHostMain.ts | 7 +- .../test/node/extensionHostSourceMaps.test.ts | 192 ++++++++++++++++++ .../localProcessExtensionHost.ts | 2 + 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/api/test/node/extensionHostSourceMaps.test.ts diff --git a/src/bootstrap-fork.ts b/src/bootstrap-fork.ts index 09b8805888f0d..e166584b35825 100644 --- a/src/bootstrap-fork.ts +++ b/src/bootstrap-fork.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { setSourceMapsSupport } from 'node:module'; import * as performance from './vs/base/common/performance.js'; import { removeGlobalNodeJsModuleLookupPaths, devInjectNodeModuleLookupPath } from './bootstrap-node.js'; import { bootstrapESM } from './bootstrap-esm.js'; @@ -229,6 +230,11 @@ if (process.env['VSCODE_PARENT_PID']) { terminateWhenParentTerminates(); } +// Enable source maps before loading the extension host and its extensions. +if (process.env['VSCODE_ENABLE_SOURCE_MAPS'] === 'true') { + setSourceMapsSupport(true); +} + // Bootstrap ESM await bootstrapESM(); diff --git a/src/vs/workbench/api/common/extensionHostMain.ts b/src/vs/workbench/api/common/extensionHostMain.ts index 0883e52b7f128..df0010d4026cf 100644 --- a/src/vs/workbench/api/common/extensionHostMain.ts +++ b/src/vs/workbench/api/common/extensionHostMain.ts @@ -72,6 +72,8 @@ export abstract class ErrorHandler { // PART 1 // set the prepareStackTrace-handle and use it as a side-effect to associate errors // with extensions - this works by looking up callsites in the extension path index + // Preserve Node's formatter: formatting call sites ourselves bypasses its source maps. + const defaultPrepareStackTrace = Error.prepareStackTrace as ((error: Error, stackTrace: errors.V8CallSite[]) => string) | undefined; function prepareStackTraceAndFindExtension(error: Error, stackTrace: errors.V8CallSite[]) { if (extensionErrors.has(error)) { return extensionErrors.get(error)!.stack; @@ -86,7 +88,10 @@ export abstract class ErrorHandler { extension = extensionsMap.findSubstr(URI.file(fileName)); } } - const result = `${error.name || 'Error'}: ${error.message || ''}${stackTraceMessage}`; + const sourceMapsEnabled = (globalThis as { process?: { sourceMapsEnabled?: boolean } }).process?.sourceMapsEnabled === true; + const result = sourceMapsEnabled && defaultPrepareStackTrace + ? defaultPrepareStackTrace(error, stackTrace) + : `${error.name || 'Error'}: ${error.message || ''}${stackTraceMessage}`; extensionErrors.set(error, { extensionIdentifier: extension?.identifier, stack: result }); return result; } diff --git a/src/vs/workbench/api/test/node/extensionHostSourceMaps.test.ts b/src/vs/workbench/api/test/node/extensionHostSourceMaps.test.ts new file mode 100644 index 0000000000000..de95bdddc806e --- /dev/null +++ b/src/vs/workbench/api/test/node/extensionHostSourceMaps.test.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { execFile } from 'child_process'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { fileURLToPath } from 'url'; +import { promisify } from 'util'; +import { dirname, join, relative } from '../../../../base/common/path.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +const execFileAsync = promisify(execFile); + +suite('Extension host native source maps', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const outRoot = new URL('../../../../../', import.meta.url); + const bootstrapPath = fileURLToPath(new URL('bootstrap-fork.js', outRoot)); + const moduleUrl = (path: string) => JSON.stringify(new URL(path, outRoot).href); + let fixtureDirectory: string; + let runnerPath: string; + let entrypointPath: string; + + suiteSetup(async () => { + fixtureDirectory = await mkdtemp(join(tmpdir(), 'vscode-extension-source-maps-')); + runnerPath = join(fixtureDirectory, 'runner.mjs'); + entrypointPath = join(fixtureDirectory, 'entrypoint.js'); + await writeFile(join(fixtureDirectory, 'package.json'), JSON.stringify({ type: 'module' })); + + const source = `export function createError(): Error { return new Error('source map probe'); }`; + const sourceMap = JSON.stringify({ version: 3, sources: ['fixture.ts'], sourcesContent: [source], names: [], mappings: 'AAAA' }); + await writeFile(join(fixtureDirectory, 'fixture.cjs'), `module.exports.createError = function createError() { return new Error('source map probe'); };\n//# sourceMappingURL=fixture.cjs.map\n`); + await writeFile(join(fixtureDirectory, 'fixture.cjs.map'), sourceMap); + + await writeFile(entrypointPath, ` + import { createRequire } from 'node:module'; + const fixture = createRequire(import.meta.url)('./fixture.cjs'); + process.stdout.write(JSON.stringify({ enabled: process.sourceMapsEnabled, stack: fixture.createError().stack })); + `); + + // Error.prepareStackTrace becomes non-configurable in the host. Use a fresh + // process per case, rather than changing the test runner's global handler. + await writeFile(runnerPath, ` + import { createRequire, setSourceMapsSupport } from 'node:module'; + const require = createRequire(import.meta.url); + globalThis._VSCODE_PRODUCT_JSON = require(${JSON.stringify(fileURLToPath(new URL('../product.json', outRoot)))}); + globalThis._VSCODE_PACKAGE_JSON = require(${JSON.stringify(fileURLToPath(new URL('../package.json', outRoot)))}); + globalThis._VSCODE_FILE_ROOT = ${JSON.stringify(outRoot.href)}; + + const mode = process.argv[2]; + setSourceMapsSupport(mode !== 'off'); + if (mode === 'custom-before') { + Error.prepareStackTrace = () => 'custom-before'; + } + const fixture = require('./fixture.cjs'); + const expectedStack = fixture.createError().stack.split('\\n').slice(0, 2).join('\\n'); + + const { ErrorHandler } = await import(${moduleUrl('vs/workbench/api/common/extensionHostMain.js')}); + const { IExtHostRpcService } = await import(${moduleUrl('vs/workbench/api/common/extHostRpcService.js')}); + const { IExtHostExtensionService } = await import(${moduleUrl('vs/workbench/api/common/extHostExtensionService.js')}); + const { IExtHostTelemetry } = await import(${moduleUrl('vs/workbench/api/common/extHostTelemetry.js')}); + const { IExtHostApiDeprecationService } = await import(${moduleUrl('vs/workbench/api/common/extHostApiDeprecationService.js')}); + const { ILogService } = await import(${moduleUrl('vs/platform/log/common/log.js')}); + const { onUnexpectedError } = await import(${moduleUrl('vs/base/common/errors.js')}); + const { ExtensionIdentifier } = await import(${moduleUrl('vs/platform/extensions/common/extensions.js')}); + + const extension = { identifier: new ExtensionIdentifier('test.source-maps') }; + const runtimeErrors = []; + const telemetryErrors = []; + let attributionLookups = 0; + const mainThread = { + $onExtensionRuntimeError(id, error) { runtimeErrors.push({ extension: id.value, stack: error.stack }); }, + $onUnexpectedError() {} + }; + const fixturePath = require.resolve('./fixture.cjs'); + const services = new Map([ + [ILogService, { error() {}, trace() {} }], + [IExtHostRpcService, { getProxy() { return mainThread; } }], + [IExtHostTelemetry, { onExtensionError(id) { telemetryErrors.push(id.value); return false; } }], + [IExtHostApiDeprecationService, {}], + [IExtHostExtensionService, { + async getExtensionRegistry() { return {}; }, + async getExtensionPathIndex() { + return { findSubstr(uri) { + attributionLookups++; + return uri.fsPath === fixturePath ? extension : undefined; + } }; + } + }] + ]); + await ErrorHandler.installFullHandler({ get(id) { return services.get(id); } }); + if (mode === 'custom-after') { + Error.prepareStackTrace = () => 'custom-after'; + } else if (mode === 'disable-after') { + setSourceMapsSupport(false); + } + const error = fixture.createError(); + const stack = error.stack; + onUnexpectedError(error); + process.stdout.write(JSON.stringify({ stack, expectedStack, attributionLookups, runtimeErrors, telemetryErrors })); + `); + }); + + suiteTeardown(async () => { + await rm(fixtureDirectory, { recursive: true, force: true }); + }); + + function childEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.NODE_OPTIONS; + delete env.VSCODE_PARENT_PID; + delete env.VSCODE_PIPE_LOGGING; + delete env.VSCODE_CODE_CACHE_PATH; + delete env.VSCODE_CRASH_REPORTER_PROCESS_TYPE; + delete env.VSCODE_NLS_CONFIG; + env.ELECTRON_RUN_AS_NODE = '1'; + env.VSCODE_DEV = '1'; + return env; + } + + interface HandlerResult { + stack: string; + expectedStack: string; + attributionLookups: number; + runtimeErrors: { extension: string; stack: string }[]; + telemetryErrors: string[]; + } + + async function runHandler(mode: string): Promise { + const { stdout } = await execFileAsync(process.execPath, [runnerPath, mode], { env: childEnvironment() }); + return JSON.parse(stdout); + } + + function assertAttribution(result: HandlerResult): void { + assert.deepStrictEqual({ + lookups: result.attributionLookups, + runtime: result.runtimeErrors, + telemetry: result.telemetryErrors + }, { + lookups: 1, + runtime: [{ extension: 'test.source-maps', stack: result.stack }], + telemetry: ['test.source-maps'] + }); + } + + test('maps generated frames using the original formatter and still attributes the error', async () => { + const result = await runHandler('on'); + assert.strictEqual(result.stack.split('\n').slice(0, 2).join('\n'), result.expectedStack); + assert.match(result.stack, /fixture\.ts:1:1/); + assertAttribution(result); + }); + + test('keeps the existing stack format when source maps are disabled', async () => { + const result = await runHandler('off'); + // The host's existing formatter uses tabs rather than Node's four spaces. + assert.strictEqual(result.stack.split('\n').slice(0, 2).join('\n'), result.expectedStack.replace('\n at ', '\n\tat ')); + assert.match(result.stack, /fixture\.cjs:1:/); + assertAttribution(result); + }); + + test('respects source maps being disabled after the handler is installed', async () => { + const result = await runHandler('disable-after'); + assert.match(result.stack, /fixture\.cjs:1:/); + assertAttribution(result); + }); + + for (const mode of ['custom-before', 'custom-after']) { + test(`preserves a custom formatter installed ${mode === 'custom-before' ? 'before' : 'after'} the host handler`, async () => { + const result = await runHandler(mode); + assert.strictEqual(result.stack, mode); + assertAttribution(result); + }); + } + + for (const flag of [undefined, 'false', 'true']) { + test(`bootstrap enables native source maps only with the opt-in flag (${flag ?? 'unset'})`, async () => { + const env = childEnvironment(); + delete env.VSCODE_ENABLE_SOURCE_MAPS; + if (flag !== undefined) { + env.VSCODE_ENABLE_SOURCE_MAPS = flag; + } + env.VSCODE_ESM_ENTRYPOINT = relative(dirname(bootstrapPath), entrypointPath).replace(/\\/g, '/').replace(/\.js$/, ''); + const { stdout } = await execFileAsync(process.execPath, [bootstrapPath], { env }); + const result = JSON.parse(stdout) as { enabled: boolean; stack: string }; + assert.strictEqual(result.enabled, flag === 'true'); + assert.match(result.stack, flag === 'true' ? /fixture\.ts:1:1/ : /fixture\.cjs:1:/); + }); + } +}); diff --git a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts index 4f9b5871a8948..ad8b6509fcdd2 100644 --- a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts +++ b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts @@ -239,6 +239,8 @@ export class NativeLocalProcessExtensionHost extends Disposable implements IExte removeDangerousEnvVariables(env); if (this._isExtensionDevHost) { + env['VSCODE_ENABLE_SOURCE_MAPS'] = 'true'; + // Unset `VSCODE_CODE_CACHE_PATH` when developing extensions because it might // be that dependencies, that otherwise would be cached, get modified. delete env['VSCODE_CODE_CACHE_PATH']; From e81fe03573e007e2be103e29c95b98bc86e76bfe Mon Sep 17 00:00:00 2001 From: Goga Koreli <12997449+gogakoreli@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:19:29 -0700 Subject: [PATCH 2/2] Extension host: compose native stack formatters without assertions Infer the captured Error.prepareStackTrace hook and contextually type the wrapper from its declared callback contract instead of asserting a narrower V8CallSite signature. Preserve Error as the captured formatter's receiver. Select the formatter before the attribution walk and skip unused fallback string rendering. Add regression coverage for receiver-sensitive formatters and zero fallback rendering calls while preserving runtime attribution. X-AI-Prompt: Address reviewer feedback with inferred formatter types and regression tests. X-AI-Tool: OpenAI coding assistant --- .../workbench/api/common/extensionHostMain.ts | 16 +++++---- .../test/node/extensionHostSourceMaps.test.ts | 36 ++++++++++++++++--- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/api/common/extensionHostMain.ts b/src/vs/workbench/api/common/extensionHostMain.ts index df0010d4026cf..1b1e6f42506be 100644 --- a/src/vs/workbench/api/common/extensionHostMain.ts +++ b/src/vs/workbench/api/common/extensionHostMain.ts @@ -73,28 +73,30 @@ export abstract class ErrorHandler { // set the prepareStackTrace-handle and use it as a side-effect to associate errors // with extensions - this works by looking up callsites in the extension path index // Preserve Node's formatter: formatting call sites ourselves bypasses its source maps. - const defaultPrepareStackTrace = Error.prepareStackTrace as ((error: Error, stackTrace: errors.V8CallSite[]) => string) | undefined; - function prepareStackTraceAndFindExtension(error: Error, stackTrace: errors.V8CallSite[]) { + const defaultPrepareStackTrace = Error.prepareStackTrace; + const prepareStackTraceAndFindExtension: typeof Error.prepareStackTrace = (error, stackTrace) => { if (extensionErrors.has(error)) { return extensionErrors.get(error)!.stack; } + const formatter = globalThis.process?.sourceMapsEnabled ? defaultPrepareStackTrace : undefined; let stackTraceMessage = ''; let extension: IExtensionDescription | undefined; let fileName: string | null; for (const call of stackTrace) { - stackTraceMessage += `\n\tat ${call.toString()}`; + if (!formatter) { + stackTraceMessage += `\n\tat ${call.toString()}`; + } fileName = call.getFileName(); if (!extension && fileName) { extension = extensionsMap.findSubstr(URI.file(fileName)); } } - const sourceMapsEnabled = (globalThis as { process?: { sourceMapsEnabled?: boolean } }).process?.sourceMapsEnabled === true; - const result = sourceMapsEnabled && defaultPrepareStackTrace - ? defaultPrepareStackTrace(error, stackTrace) + const result = formatter + ? formatter.call(Error, error, stackTrace) : `${error.name || 'Error'}: ${error.message || ''}${stackTraceMessage}`; extensionErrors.set(error, { extensionIdentifier: extension?.identifier, stack: result }); return result; - } + }; const _wasWrapped = Symbol('prepareStackTrace wrapped'); let _prepareStackTrace = prepareStackTraceAndFindExtension; diff --git a/src/vs/workbench/api/test/node/extensionHostSourceMaps.test.ts b/src/vs/workbench/api/test/node/extensionHostSourceMaps.test.ts index de95bdddc806e..929a17472a524 100644 --- a/src/vs/workbench/api/test/node/extensionHostSourceMaps.test.ts +++ b/src/vs/workbench/api/test/node/extensionHostSourceMaps.test.ts @@ -52,8 +52,13 @@ suite('Extension host native source maps', () => { const mode = process.argv[2]; setSourceMapsSupport(mode !== 'off'); - if (mode === 'custom-before') { - Error.prepareStackTrace = () => 'custom-before'; + if (mode === 'custom-before' || mode === 'skip-fallback') { + Error.prepareStackTrace = function () { + if (mode === 'custom-before' && this !== Error) { + throw new Error('Unexpected formatter receiver'); + } + return mode; + }; } const fixture = require('./fixture.cjs'); const expectedStack = fixture.createError().stack.split('\\n').slice(0, 2).join('\\n'); @@ -93,14 +98,28 @@ suite('Extension host native source maps', () => { ]); await ErrorHandler.installFullHandler({ get(id) { return services.get(id); } }); if (mode === 'custom-after') { - Error.prepareStackTrace = () => 'custom-after'; + Error.prepareStackTrace = function () { + if (this !== Error) { + throw new Error('Unexpected formatter receiver'); + } + return mode; + }; } else if (mode === 'disable-after') { setSourceMapsSupport(false); } const error = fixture.createError(); + let fallbackFormattingCalls = 0; + if (mode === 'skip-fallback') { + // Invoke the real host handler with a frame whose fallback rendering + // can be counted, while the installed formatter supplies the output. + Error.prepareStackTrace.call(Error, error, [{ + getFileName() { return fixturePath; }, + toString() { fallbackFormattingCalls++; return 'generated frame'; } + }]); + } const stack = error.stack; onUnexpectedError(error); - process.stdout.write(JSON.stringify({ stack, expectedStack, attributionLookups, runtimeErrors, telemetryErrors })); + process.stdout.write(JSON.stringify({ stack, expectedStack, attributionLookups, runtimeErrors, telemetryErrors, fallbackFormattingCalls })); `); }); @@ -127,6 +146,7 @@ suite('Extension host native source maps', () => { attributionLookups: number; runtimeErrors: { extension: string; stack: string }[]; telemetryErrors: string[]; + fallbackFormattingCalls: number; } async function runHandler(mode: string): Promise { @@ -167,8 +187,14 @@ suite('Extension host native source maps', () => { assertAttribution(result); }); + test('does not build fallback stack text when a formatter is available', async () => { + const result = await runHandler('skip-fallback'); + assert.deepStrictEqual({ stack: result.stack, fallbackCalls: result.fallbackFormattingCalls }, { stack: 'skip-fallback', fallbackCalls: 0 }); + assertAttribution(result); + }); + for (const mode of ['custom-before', 'custom-after']) { - test(`preserves a custom formatter installed ${mode === 'custom-before' ? 'before' : 'after'} the host handler`, async () => { + test(`preserves a custom formatter and its Error receiver when installed ${mode === 'custom-before' ? 'before' : 'after'} the host handler`, async () => { const result = await runHandler(mode); assert.strictEqual(result.stack, mode); assertAttribution(result);