From dd15b4f45a8bf50c9d212aebc157043e1917f262 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:22:25 +0000 Subject: [PATCH 1/8] Initial plan From e1af4f367f544a4e45c658f91aca334268bde6c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:34:24 +0000 Subject: [PATCH 2/8] feat: add NullFormatter and isFormatter to fix duplicate formatter registration (#752) --- CHANGELOG.md | 34 ++++ VERSION | 2 +- typescript/package-lock.json | 4 +- typescript/package.json | 2 +- typescript/src/activation.ts | 25 +++ typescript/src/index.ts | 3 + typescript/src/nullFormatter.ts | 63 ++++++++ typescript/src/types.ts | 19 +++ typescript/tests/_languageclient_mock.ts | 10 ++ typescript/tests/activation.test.ts | 190 +++++++++++++++++++++++ 10 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 typescript/src/nullFormatter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2ec4e..5402731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-06-17 + +### Added + +- **TypeScript** `ToolConfig.isFormatter` — optional boolean flag for tool + extensions that provide LSP-based document formatting + (`textDocument/formatting`, `rangeFormatting`, `rangesFormatting`). + +- **TypeScript** `NullFormatter` class — a lifecycle-aware placeholder + `DocumentFormattingEditProvider`. It registers a no-op formatter at + activation time (so VS Code lists the extension in the formatter picker + *before* the LSP has finished starting) and exposes `register()`, + `unregister()`, `isRegistered()`, and `dispose()` for manual control. + Exported from the package root. + +- **TypeScript** `createToolContext` — when `ToolConfig.isFormatter` is + `true`, the shared activation pattern now automatically: + - registers the `NullFormatter` placeholder immediately at activation, + - disposes it when the language client transitions to `State.Running` + (including the "already Running" case where the initial transition would + otherwise be missed), + - re-registers it if the client later transitions to `State.Stopped` or + `State.Starting` (e.g. during a restart), and disposes it again on the + next `Running`, + - disposes it on `ctx.dispose()` / `deactivateServer()`. + + This fixes the "duplicate formatter entry" regression reported in + [microsoft/vscode-black-formatter#752](https://github.com/microsoft/vscode-black-formatter/issues/752) + and unblocks the same fix for other formatter extensions + (`vscode-autopep8`, `vscode-isort`, …). + + Linter-only extensions (pylint, flake8, mypy, …) are unaffected — the + placeholder is **not** registered when `isFormatter` is `false` or unset. + ## [0.6.1] - 2026-06-17 ### Fixed diff --git a/VERSION b/VERSION index ee6cdce..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.1 +0.7.0 diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 5031021..957776c 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/common-python-lsp", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/common-python-lsp", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", "dependencies": { "@vscode/python-environments": "https://pkgs.dev.azure.com/azure-public/vside/_packaging/msft_consumption/npm/registry/@vscode/python-environments/-/python-environments-1.0.0.tgz", diff --git a/typescript/package.json b/typescript/package.json index 7c8c03b..2a3f42e 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/common-python-lsp", - "version": "0.6.1", + "version": "0.7.0", "description": "Shared TypeScript utilities for VS Code Python tool extensions", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 592ef14..4b715d4 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -11,9 +11,11 @@ */ import * as vscode from 'vscode'; +import { State } from 'vscode-languageclient'; import { LanguageClient } from 'vscode-languageclient/node'; import { createConfigFileWatchers } from './configWatcher'; import { traceError, traceLog, traceVerbose } from './logging'; +import { NullFormatter } from './nullFormatter'; import { PythonEnvironmentsProvider } from './python'; import { restartServer, RestartServerOptions } from './server'; import { checkIfConfigurationChanged, getWorkspaceSettings } from './settings'; @@ -100,6 +102,9 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens let disposed = false; let serverDisposables: vscode.Disposable[] = []; + const nullFormatter = toolConfig.isFormatter ? new NullFormatter() : undefined; + nullFormatter?.register(); + const ctx: ToolExtensionContext = { lsClient: undefined, @@ -207,6 +212,25 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens ctx.lsClient = result.client; serverDisposables = result.disposables; + + if (nullFormatter && result.client) { + if (result.client.state === State.Running) { + nullFormatter.unregister(); + } + serverDisposables.push( + result.client.onDidChangeState((e) => { + switch (e.newState) { + case State.Running: + nullFormatter.unregister(); + break; + case State.Stopped: + case State.Starting: + nullFormatter.register(); + break; + } + }), + ); + } } } catch (ex) { traceError(`Server restart failed: ${ex}`); @@ -244,6 +268,7 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens } } serverDisposables = []; + nullFormatter?.dispose(); }, }; diff --git a/typescript/src/index.ts b/typescript/src/index.ts index b46e099..2282511 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -69,6 +69,9 @@ export { RestartServerResult, } from './server'; +// Null formatter helper +export { NullFormatter } from './nullFormatter'; + // Activation / deactivation export { createToolContext, diff --git a/typescript/src/nullFormatter.ts b/typescript/src/nullFormatter.ts new file mode 100644 index 0000000..6ac812e --- /dev/null +++ b/typescript/src/nullFormatter.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Disposable } from 'vscode'; +import { traceLog } from './logging'; +import { getDocumentSelector } from './utilities'; +import { registerDocumentFormattingEditProvider } from './vscodeapi'; + +/** + * Lifecycle-aware placeholder DocumentFormattingEditProvider. + * + * A tool extension that provides LSP-based formatting needs to appear in + * VS Code's formatter picker *before* the language client has finished + * starting, otherwise the first "Format Document" invocation after + * activation is treated as "no formatter installed". + * + * This helper registers a no-op provider for `getDocumentSelector()` and + * lets callers dispose it (typically as soon as the real LSP provider takes + * over). + * + * **Important:** never leave both the placeholder and the LSP provider + * registered at the same time — VS Code will show the extension twice in + * the formatter picker (one entry per provider). The shared activation + * pattern in `activation.ts` handles this automatically when + * `ToolConfig.isFormatter` is `true`. + */ +export class NullFormatter implements Disposable { + private disposable: Disposable | undefined; + + /** No-op if already registered. */ + register(): void { + if (this.disposable) { + return; + } + this.disposable = registerDocumentFormattingEditProvider(getDocumentSelector(), { + provideDocumentFormattingEdits: () => { + traceLog('Formatting requested before server has started.'); + return Promise.resolve(undefined); + }, + }); + } + + /** No-op if not registered. */ + unregister(): void { + if (!this.disposable) { + return; + } + try { + this.disposable.dispose(); + } finally { + this.disposable = undefined; + } + } + + /** True iff the placeholder is currently registered. */ + isRegistered(): boolean { + return this.disposable !== undefined; + } + + dispose(): void { + this.unregister(); + } +} diff --git a/typescript/src/types.ts b/typescript/src/types.ts index a8670e4..1577587 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -53,6 +53,25 @@ export interface ToolConfig { // Environment extraEnvVars?: Record; + + /** + * Set to `true` for tools that provide LSP formatting (textDocument/formatting, + * rangeFormatting, rangesFormatting). + * + * When enabled, the common activation pattern will: + * - register a placeholder DocumentFormattingEditProvider at activation time + * (so VS Code lists the extension as a formatter *before* the LSP has + * finished starting), + * - dispose it automatically when the language client transitions to + * `Running` (so VS Code does not see two providers for the same selector + * and list the extension twice in the formatter picker), + * - re-register it if the client transitions back to `Stopped` / `Starting` + * during a restart, then dispose it again on the next `Running`. + * + * Defaults to `false`. Linter-only tools (pylint, flake8, mypy, …) should + * leave this unset. + */ + isFormatter?: boolean; } /** diff --git a/typescript/tests/_languageclient_mock.ts b/typescript/tests/_languageclient_mock.ts index 1e09a41..caa968e 100644 --- a/typescript/tests/_languageclient_mock.ts +++ b/typescript/tests/_languageclient_mock.ts @@ -36,6 +36,10 @@ export class LanguageClient { this.clientOptions = clientOptions; } + get state(): State { + return this._state; + } + onDidChangeState(listener: (e: { oldState: State; newState: State }) => void) { this._stateListeners.push(listener); return { @@ -46,6 +50,12 @@ export class LanguageClient { }; } + simulateStateChange(newState: State): void { + const oldState = this._state; + this._state = newState; + this._stateListeners.forEach((l) => l({ oldState, newState })); + } + async start(): Promise { this._state = State.Running; } diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index 5fd592c..cc2b61d 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -5,6 +5,7 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/node'; +import { State } from 'vscode-languageclient'; import { createToolContext, CreateToolContextOptions, @@ -376,3 +377,192 @@ suite('deactivateServer', () => { assert.isTrue(dispose.calledOnce); }); }); + +// --------------------------------------------------------------------------- +// NullFormatter lifecycle (via createToolContext with isFormatter: true) +// --------------------------------------------------------------------------- + +suite('createToolContext – NullFormatter lifecycle', () => { + let sandbox: sinon.SinonSandbox; + let registerFormattingProviderStub: sinon.SinonStub; + let providerDispose: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(utilities, 'getProjectRoot').resolves(undefined); + sandbox.stub(utilities, 'getInterpreterFromSetting').returns(['/usr/bin/python3']); + sandbox.stub(settingsModule, 'getWorkspaceSettings').resolves({ + cwd: '/workspace', + workspace: 'file:///workspace', + args: [], + path: [], + interpreter: ['/usr/bin/python3'], + importStrategy: 'useBundled', + showNotifications: 'off', + }); + + providerDispose = sandbox.stub(); + registerFormattingProviderStub = sandbox + .stub(vscodeapi, 'registerDocumentFormattingEditProvider') + .returns({ dispose: providerDispose }); + }); + + teardown(() => { + sandbox.restore(); + }); + + function makeFormatterOptions(overrides?: Partial): CreateToolContextOptions { + return { + serverInfo: { name: 'Black', module: 'black' }, + outputChannel: { + logLevel: vscode.LogLevel.Info, + show: sinon.stub(), + onDidChangeLogLevel: sinon.stub().returns({ dispose: sinon.stub() }), + } as unknown as vscode.LogOutputChannel, + toolConfig: { + toolId: 'black', + toolDisplayName: 'Black', + toolModule: 'black', + minimumPythonVersion: { major: 3, minor: 8 }, + configFiles: ['pyproject.toml'], + settingsDefaults: {}, + trackedSettings: ['args'], + serverScript: '/path/to/server.py', + isFormatter: true, + }, + pythonProvider: { + getInterpreterDetails: sandbox.stub().resolves({ path: ['/usr/bin/python3'] }), + getDebuggerPath: sandbox.stub().resolves(undefined), + initializePython: sandbox.stub().resolves(), + onDidChangeInterpreter: sinon.stub().returns({ dispose: sinon.stub() }), + } as unknown as PythonEnvironmentsProvider, + ...overrides, + }; + } + + // Helper that creates a mock client with an explicit initial state and a + // simulateStateChange method, bypassing the private _state field via `any`. + function makeMockClient(initialState: State = State.Stopped): LanguageClient & { simulateStateChange(s: State): void } { + const stateListeners: Array<(e: { oldState: State; newState: State }) => void> = []; + let currentState = initialState; + return { + state: initialState, + get _currentState() { return currentState; }, + onDidChangeState(listener: (e: { oldState: State; newState: State }) => void) { + stateListeners.push(listener); + return { dispose: () => { stateListeners.splice(stateListeners.indexOf(listener), 1); } }; + }, + simulateStateChange(newState: State) { + const oldState = currentState; + currentState = newState; + (this as any).state = newState; + stateListeners.forEach((l) => l({ oldState, newState })); + }, + stop: sandbox.stub().resolves(), + dispose: sandbox.stub(), + setTrace: sandbox.stub().resolves(), + start: sandbox.stub().resolves(), + } as unknown as LanguageClient & { simulateStateChange(s: State): void }; + } + + // Test 1: provider registered once at createToolContext, disposed on State.Running + test('registers placeholder once at activation and disposes it on State.Running', async () => { + const mockClient = makeMockClient(State.Stopped); + sandbox.stub(serverModule, 'restartServer').resolves({ client: mockClient as unknown as LanguageClient, disposables: [] }); + + const ctx = createToolContext(makeFormatterOptions()); + + assert.isTrue(registerFormattingProviderStub.calledOnce, 'provider registered at activation'); + assert.isFalse(providerDispose.called, 'not yet disposed before server starts'); + + await ctx.runServer(); + + // Client is Stopped — "already Running" guard does not fire + assert.isFalse(providerDispose.called, 'not disposed before Running transition'); + + // Emit Running — state listener should dispose the placeholder + mockClient.simulateStateChange(State.Running); + assert.isTrue(providerDispose.calledOnce, 'placeholder disposed on State.Running'); + }); + + // Test 2: placeholder re-registered on Stopped/Starting, disposed again on Running + test('re-registers and disposes placeholder across Stopped → Starting → Running cycle', async () => { + const mockClient = makeMockClient(State.Stopped); + sandbox.stub(serverModule, 'restartServer').resolves({ client: mockClient as unknown as LanguageClient, disposables: [] }); + + const ctx = createToolContext(makeFormatterOptions()); + assert.isTrue(registerFormattingProviderStub.calledOnce, 'provider registered at activation'); + + await ctx.runServer(); + + // Simulate Running — placeholder disposed + mockClient.simulateStateChange(State.Running); + assert.strictEqual(providerDispose.callCount, 1, 'placeholder disposed on Running'); + + // Simulate Stopped — placeholder re-registered + mockClient.simulateStateChange(State.Stopped); + assert.strictEqual(registerFormattingProviderStub.callCount, 2, 'placeholder re-registered on Stopped'); + + // Simulate Starting — placeholder already registered, no double-registration + mockClient.simulateStateChange(State.Starting); + assert.strictEqual(registerFormattingProviderStub.callCount, 2, 'no double-registration on Starting'); + + // Simulate Running again — placeholder disposed again + mockClient.simulateStateChange(State.Running); + assert.strictEqual(providerDispose.callCount, 2, 'placeholder disposed again on Running'); + }); + + // Test 3: already-Running guard — client is Running when runServer() returns + test('disposes placeholder immediately when client is already Running at runServer return', async () => { + const mockClient = makeMockClient(State.Running); + sandbox.stub(serverModule, 'restartServer').resolves({ client: mockClient as unknown as LanguageClient, disposables: [] }); + + const ctx = createToolContext(makeFormatterOptions()); + assert.isTrue(registerFormattingProviderStub.calledOnce, 'provider registered at activation'); + + await ctx.runServer(); + + assert.isTrue( + providerDispose.calledOnce, + 'placeholder must be disposed even when client is already Running (missed initial transition)', + ); + }); + + // Test 4: isFormatter: false — no placeholder, no state listener + test('does not register placeholder when isFormatter is false', async () => { + const options = makeFormatterOptions(); + options.toolConfig = { ...options.toolConfig, isFormatter: false }; + sandbox.stub(serverModule, 'restartServer').resolves({ client: undefined, disposables: [] }); + + createToolContext(options); + + assert.isFalse( + registerFormattingProviderStub.called, + 'should not register provider when isFormatter is false', + ); + }); + + // Test 4b: isFormatter unset — no placeholder + test('does not register placeholder when isFormatter is unset', async () => { + const options = makeFormatterOptions(); + const { isFormatter: _f, ...configWithoutFormatter } = options.toolConfig; + options.toolConfig = configWithoutFormatter as ToolConfig; + sandbox.stub(serverModule, 'restartServer').resolves({ client: undefined, disposables: [] }); + + createToolContext(options); + + assert.isFalse( + registerFormattingProviderStub.called, + 'should not register provider when isFormatter is unset', + ); + }); + + // Test 5: ctx.dispose() disposes placeholder + test('ctx.dispose() disposes the placeholder when it is registered', () => { + const ctx = createToolContext(makeFormatterOptions()); + assert.isTrue(registerFormattingProviderStub.calledOnce, 'provider registered at activation'); + + ctx.dispose(); + assert.isTrue(providerDispose.calledOnce, 'placeholder disposed on ctx.dispose()'); + }); +}); From 6b5763e4eaf5f8fec7db0241e162e39303ed213b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:35:35 +0000 Subject: [PATCH 3/8] fix: address code review comments in NullFormatter tests --- typescript/tests/activation.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index cc2b61d..a785f44 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -542,10 +542,10 @@ suite('createToolContext – NullFormatter lifecycle', () => { ); }); - // Test 4b: isFormatter unset — no placeholder + // Test 5: isFormatter unset — no placeholder test('does not register placeholder when isFormatter is unset', async () => { const options = makeFormatterOptions(); - const { isFormatter: _f, ...configWithoutFormatter } = options.toolConfig; + const { isFormatter, ...configWithoutFormatter } = options.toolConfig; options.toolConfig = configWithoutFormatter as ToolConfig; sandbox.stub(serverModule, 'restartServer').resolves({ client: undefined, disposables: [] }); @@ -557,7 +557,7 @@ suite('createToolContext – NullFormatter lifecycle', () => { ); }); - // Test 5: ctx.dispose() disposes placeholder + // Test 6: ctx.dispose() disposes placeholder test('ctx.dispose() disposes the placeholder when it is registered', () => { const ctx = createToolContext(makeFormatterOptions()); assert.isTrue(registerFormattingProviderStub.calledOnce, 'provider registered at activation'); From d24f3268e2c293c0485894c5606b8d5ffb2cedfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:52:41 +0000 Subject: [PATCH 4/8] Sync pyproject.toml version to 0.7.0 --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 6a78cc6..1777daf 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vscode-common-python-lsp" -version = "0.6.1" +version = "0.7.0" description = "Shared Python utilities for VS Code Python tool extensions" readme = "README.md" license = "MIT" From 7e8fa922bcc25843c2ae62108718a1127c6e824e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 11:53:51 -0700 Subject: [PATCH 5/8] fix: address review comments on NullFormatter lifecycle - Re-register placeholder at the start of each runServer() cycle to cover extension-driven restarts (where the old state listener is disposed before restartServer stops the previous client) and failure paths (restartServer throws or returns no client). - Replace inline makeMockClient with shared LanguageClient mock from _languageclient_mock.ts so test and production getter semantics stay in sync. - Rename Test 2 to reflect it exercises crash/recovery, not the extension-driven restart path. - Add Test 7 (second runServer call) to exercise the dispose-then- reattach path for extension-driven restarts. - Add Test 8 to verify placeholder stays registered when restartServer returns no client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- typescript/src/activation.ts | 13 ++++ typescript/tests/activation.test.ts | 101 ++++++++++++++++++++-------- 2 files changed, 86 insertions(+), 28 deletions(-) diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 4b715d4..3a596f0 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -121,6 +121,19 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens } isRestarting = true; try { + // Re-register the placeholder at the start of each restart + // cycle. This covers two gaps the per-state listener misses: + // 1. Extension-driven restarts (config/interpreter change): + // runServer() disposes the old state listener *before* + // restartServer() stops the previous client, so + // Stopped/Starting transitions fire into a dead listener. + // 2. Failure paths: if restartServer() throws or returns + // client: undefined the state-listener block is skipped + // entirely. + // register() is a no-op when the placeholder is already + // registered, so this is always safe to call. + nullFormatter?.register(); + const projectRoot = await getProjectRoot(); if (disposed) { return; diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index a785f44..4785e16 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -440,35 +440,33 @@ suite('createToolContext – NullFormatter lifecycle', () => { }; } - // Helper that creates a mock client with an explicit initial state and a - // simulateStateChange method, bypassing the private _state field via `any`. - function makeMockClient(initialState: State = State.Stopped): LanguageClient & { simulateStateChange(s: State): void } { - const stateListeners: Array<(e: { oldState: State; newState: State }) => void> = []; - let currentState = initialState; - return { - state: initialState, - get _currentState() { return currentState; }, - onDidChangeState(listener: (e: { oldState: State; newState: State }) => void) { - stateListeners.push(listener); - return { dispose: () => { stateListeners.splice(stateListeners.indexOf(listener), 1); } }; - }, - simulateStateChange(newState: State) { - const oldState = currentState; - currentState = newState; - (this as any).state = newState; - stateListeners.forEach((l) => l({ oldState, newState })); - }, - stop: sandbox.stub().resolves(), - dispose: sandbox.stub(), - setTrace: sandbox.stub().resolves(), - start: sandbox.stub().resolves(), - } as unknown as LanguageClient & { simulateStateChange(s: State): void }; + // Uses the shared LanguageClient mock from _languageclient_mock.ts + // (wired in by _setup.ts) so production and test getter semantics + // stay in sync. At compile-time, LanguageClient resolves to the real + // vscode-languageclient type; at runtime it is replaced by the mock + // (which exposes simulateStateChange). We import the mock type for + // the helper return type so callers get proper type-checking. + type MockClient = import('./_languageclient_mock').LanguageClient; + + function makeMockClient(initialState: State = State.Stopped): MockClient { + const MockLC = LanguageClient as unknown as typeof import('./_languageclient_mock').LanguageClient; + const client = new MockLC('test', 'Test', {}, {}); + // Drive the mock to the desired initial state. + if (initialState !== State.Stopped) { + client.simulateStateChange(initialState); + } + return client; + } + + /** Cast a mock client to the real LanguageClient type for stub return values. */ + function asLC(mock: MockClient): LanguageClient { + return mock as unknown as LanguageClient; } // Test 1: provider registered once at createToolContext, disposed on State.Running test('registers placeholder once at activation and disposes it on State.Running', async () => { const mockClient = makeMockClient(State.Stopped); - sandbox.stub(serverModule, 'restartServer').resolves({ client: mockClient as unknown as LanguageClient, disposables: [] }); + sandbox.stub(serverModule, 'restartServer').resolves({ client: asLC(mockClient), disposables: [] }); const ctx = createToolContext(makeFormatterOptions()); @@ -485,10 +483,10 @@ suite('createToolContext – NullFormatter lifecycle', () => { assert.isTrue(providerDispose.calledOnce, 'placeholder disposed on State.Running'); }); - // Test 2: placeholder re-registered on Stopped/Starting, disposed again on Running - test('re-registers and disposes placeholder across Stopped → Starting → Running cycle', async () => { + // Test 2: crash/recovery — server stops while running, placeholder restored + test('re-registers placeholder on server crash (Stopped while running) and disposes on recovery', async () => { const mockClient = makeMockClient(State.Stopped); - sandbox.stub(serverModule, 'restartServer').resolves({ client: mockClient as unknown as LanguageClient, disposables: [] }); + sandbox.stub(serverModule, 'restartServer').resolves({ client: asLC(mockClient), disposables: [] }); const ctx = createToolContext(makeFormatterOptions()); assert.isTrue(registerFormattingProviderStub.calledOnce, 'provider registered at activation'); @@ -515,7 +513,7 @@ suite('createToolContext – NullFormatter lifecycle', () => { // Test 3: already-Running guard — client is Running when runServer() returns test('disposes placeholder immediately when client is already Running at runServer return', async () => { const mockClient = makeMockClient(State.Running); - sandbox.stub(serverModule, 'restartServer').resolves({ client: mockClient as unknown as LanguageClient, disposables: [] }); + sandbox.stub(serverModule, 'restartServer').resolves({ client: asLC(mockClient), disposables: [] }); const ctx = createToolContext(makeFormatterOptions()); assert.isTrue(registerFormattingProviderStub.calledOnce, 'provider registered at activation'); @@ -565,4 +563,51 @@ suite('createToolContext – NullFormatter lifecycle', () => { ctx.dispose(); assert.isTrue(providerDispose.calledOnce, 'placeholder disposed on ctx.dispose()'); }); + + // Test 7: extension-driven restart — second runServer() disposes the old + // state listener before restartServer() stops the previous client. The + // placeholder must be re-registered at the start of the restart cycle so + // it is visible while the new server starts. + test('re-registers placeholder on extension-driven restart (second runServer call)', async () => { + const firstClient = makeMockClient(State.Stopped); + const secondClient = makeMockClient(State.Stopped); + const restartStub = sandbox.stub(serverModule, 'restartServer'); + restartStub.onFirstCall().resolves({ client: asLC(firstClient), disposables: [] }); + restartStub.onSecondCall().resolves({ client: asLC(secondClient), disposables: [] }); + + const ctx = createToolContext(makeFormatterOptions()); + assert.strictEqual(registerFormattingProviderStub.callCount, 1, 'registered at activation'); + + // First run — transition to Running disposes placeholder + await ctx.runServer(); + firstClient.simulateStateChange(State.Running); + assert.strictEqual(providerDispose.callCount, 1, 'placeholder disposed after first Running'); + + // Second runServer() — simulates config/interpreter-driven restart. + // The old state listener is disposed, so transitions on firstClient + // no longer trigger re-registration. The placeholder must be + // re-registered at the top of runServer(). + await ctx.runServer(); + // After second runServer, placeholder should have been re-registered + // (call count goes from 1 → 2 during runServer) + assert.strictEqual(registerFormattingProviderStub.callCount, 2, 'placeholder re-registered at start of second runServer'); + + // Transition second client to Running — placeholder disposed again + secondClient.simulateStateChange(State.Running); + assert.strictEqual(providerDispose.callCount, 2, 'placeholder disposed after second Running'); + }); + + // Test 8: restartServer returns no client — placeholder stays registered + test('keeps placeholder registered when restartServer returns no client', async () => { + sandbox.stub(serverModule, 'restartServer').resolves({ client: undefined, disposables: [] }); + + const ctx = createToolContext(makeFormatterOptions()); + assert.strictEqual(registerFormattingProviderStub.callCount, 1, 'registered at activation'); + + await ctx.runServer(); + + // Placeholder should still be registered (register() called again at + // top of runServer as a no-op, and never unregistered). + assert.isFalse(providerDispose.called, 'placeholder must stay registered when no client'); + }); }); From 1267e3e07f79a64ef33d8059857bba1e4f557266 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 13:19:13 -0700 Subject: [PATCH 6/8] fix: guard placeholder register to avoid transient duplicate formatter - Guard nullFormatter.register() at the top of runServer() with 'if (!ctx.lsClient || ctx.lsClient.state !== State.Running)' to prevent the transient duplicate-formatter symptom (#752) when restarting a healthy Running server. - Add explicit register() in the result.client branch when the new client is not yet Running, covering the case where the guard skipped registration because the *old* client was still Running. - Add explicit register() in the catch block and the no-client else branch to cover failure paths. - Update isFormatter JSDoc in types.ts to document both the state-listener path (crash recovery) and the eager register path (extension-driven restarts). - Update Test 7 to model the first client being Running when second runServer is called, verifying the guard prevents transient duplicate and placeholder is re-registered only after restartServer returns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- typescript/src/activation.ts | 28 ++++++++++++++++++++++++---- typescript/src/types.ts | 8 +++++++- typescript/tests/activation.test.ts | 22 ++++++++++++---------- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 3a596f0..a4004f5 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -122,7 +122,8 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens isRestarting = true; try { // Re-register the placeholder at the start of each restart - // cycle. This covers two gaps the per-state listener misses: + // cycle when there is no healthy running client. This covers + // two gaps the per-state listener misses: // 1. Extension-driven restarts (config/interpreter change): // runServer() disposes the old state listener *before* // restartServer() stops the previous client, so @@ -130,9 +131,14 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens // 2. Failure paths: if restartServer() throws or returns // client: undefined the state-listener block is skipped // entirely. - // register() is a no-op when the placeholder is already - // registered, so this is always safe to call. - nullFormatter?.register(); + // The guard avoids re-introducing the duplicate-formatter + // symptom (#752): if the old client is still Running (serving + // formatting requests), re-registering the placeholder would + // make the extension appear twice in the formatter picker + // until restartServer() stops the old client. + if (nullFormatter && (!ctx.lsClient || ctx.lsClient.state !== State.Running)) { + nullFormatter.register(); + } const projectRoot = await getProjectRoot(); if (disposed) { @@ -229,6 +235,13 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens if (nullFormatter && result.client) { if (result.client.state === State.Running) { nullFormatter.unregister(); + } else { + // New client not yet Running — ensure placeholder is + // visible while the server finishes starting. This + // covers the extension-driven restart path where the + // guard at the top skipped register() because the + // *old* client was still Running at that point. + nullFormatter.register(); } serverDisposables.push( result.client.onDidChangeState((e) => { @@ -243,10 +256,17 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens } }), ); + } else if (nullFormatter && !result.client) { + // No client available — ensure placeholder is visible + // so the extension remains in the formatter picker. + nullFormatter.register(); } } } catch (ex) { traceError(`Server restart failed: ${ex}`); + // Ensure placeholder stays visible after a failure so the + // extension doesn't vanish from the formatter picker. + nullFormatter?.register(); } finally { isRestarting = false; } diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 1577587..049dbff 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -66,7 +66,13 @@ export interface ToolConfig { * `Running` (so VS Code does not see two providers for the same selector * and list the extension twice in the formatter picker), * - re-register it if the client transitions back to `Stopped` / `Starting` - * during a restart, then dispose it again on the next `Running`. + * during a crash/recovery restart, then dispose it again on the next + * `Running`, + * - re-register it at the start of each extension-driven restart cycle + * (config change, interpreter change, restart command) when the previous + * client is no longer `Running`, ensuring the placeholder is visible + * while the new server starts — and in failure paths where + * `restartServer()` throws or returns no client. * * Defaults to `false`. Linter-only tools (pylint, flake8, mypy, …) should * leave this unset. diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index 4785e16..1b15a5a 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -564,10 +564,11 @@ suite('createToolContext – NullFormatter lifecycle', () => { assert.isTrue(providerDispose.calledOnce, 'placeholder disposed on ctx.dispose()'); }); - // Test 7: extension-driven restart — second runServer() disposes the old - // state listener before restartServer() stops the previous client. The - // placeholder must be re-registered at the start of the restart cycle so - // it is visible while the new server starts. + // Test 7: extension-driven restart — second runServer() is called while + // the first client is still Running. The guard at the top of runServer + // skips re-registration to avoid the transient duplicate-formatter issue. + // The placeholder is re-registered only after restartServer returns the + // new (not-yet-Running) client. test('re-registers placeholder on extension-driven restart (second runServer call)', async () => { const firstClient = makeMockClient(State.Stopped); const secondClient = makeMockClient(State.Stopped); @@ -584,13 +585,14 @@ suite('createToolContext – NullFormatter lifecycle', () => { assert.strictEqual(providerDispose.callCount, 1, 'placeholder disposed after first Running'); // Second runServer() — simulates config/interpreter-driven restart. - // The old state listener is disposed, so transitions on firstClient - // no longer trigger re-registration. The placeholder must be - // re-registered at the top of runServer(). + // firstClient is still Running, so the guard at the top of runServer + // skips register (avoiding transient duplicate). After restartServer + // returns the new client (Stopped), the placeholder is re-registered. await ctx.runServer(); - // After second runServer, placeholder should have been re-registered - // (call count goes from 1 → 2 during runServer) - assert.strictEqual(registerFormattingProviderStub.callCount, 2, 'placeholder re-registered at start of second runServer'); + assert.strictEqual( + registerFormattingProviderStub.callCount, 2, + 'placeholder re-registered after restartServer returns non-Running client', + ); // Transition second client to Running — placeholder disposed again secondClient.simulateStateChange(State.Running); From 4a2bfc23d45be5db4e976132784842c85fc05236 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 16:00:24 -0700 Subject: [PATCH 7/8] fix: guard catch-block register() to prevent duplicate formatter on failure Apply the same state !== Running guard to the catch block's nullFormatter.register() call, preventing the duplicate-formatter symptom (#752) when a restart fails while a healthy client is still running. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- typescript/src/activation.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index a4004f5..734e623 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -264,9 +264,12 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens } } catch (ex) { traceError(`Server restart failed: ${ex}`); - // Ensure placeholder stays visible after a failure so the - // extension doesn't vanish from the formatter picker. - nullFormatter?.register(); + // Ensure placeholder stays visible after a failure — but only + // when there is no healthy running client (same guard as the + // top of runServer to avoid the duplicate-formatter symptom). + if (nullFormatter && (!ctx.lsClient || ctx.lsClient.state !== State.Running)) { + nullFormatter.register(); + } } finally { isRestarting = false; } From 76e1bad17fccaedf78c6c6a2f977c40efba5861a Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 16:52:51 -0700 Subject: [PATCH 8/8] fix: re-register placeholder on empty-interpreter path, remove inline #752 ref - Add nullFormatter.register() in the empty-interpreter branch so a Running formatter that loses its interpreter stays visible in the formatter picker. - Remove inline #752 issue reference from code comments (kept in CHANGELOG/PR only). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- typescript/src/activation.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 734e623..65ea8e5 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -132,7 +132,7 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens // client: undefined the state-listener block is skipped // entirely. // The guard avoids re-introducing the duplicate-formatter - // symptom (#752): if the old client is still Running (serving + // symptom: if the old client is still Running (serving // formatting requests), re-registering the placeholder would // make the extension appear twice in the formatter picker // until restartServer() stops the old client. @@ -173,6 +173,11 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens } serverDisposables = []; + // Re-register the placeholder so the extension stays + // visible in the formatter picker while there is no + // interpreter (and therefore no running LSP formatter). + nullFormatter?.register(); + updateStatus( vscode.l10n.t('Please select a Python interpreter.'), vscode.LanguageStatusSeverity.Error,