Skip to content

Add lifecycle-aware NullFormatter placeholder for formatter tool extensions#63

Merged
edvilme merged 8 commits into
mainfrom
copilot/add-null-formatter-registration
Jun 19, 2026
Merged

Add lifecycle-aware NullFormatter placeholder for formatter tool extensions#63
edvilme merged 8 commits into
mainfrom
copilot/add-null-formatter-registration

Conversation

Copilot AI commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Formatter extensions (black, autopep8, isort) register a placeholder DocumentFormattingEditProvider at activation so the extension appears in the picker before the LSP starts — but never dispose it once the LSP reaches Running. VS Code sees two providers with the same selector and shows the extension twice in the formatter picker (vscode-black-formatter#752).

The fix moves the full placeholder lifecycle into this shared package so every formatter extension gets it for free.

New: ToolConfig.isFormatter

Optional boolean flag. When true, createToolContext automatically manages the placeholder lifetime. Linter-only tools (pylint, flake8, mypy, …) leave it unset — zero behavioral change.

New: NullFormatter class (typescript/src/nullFormatter.ts)

Lifecycle-aware wrapper around registerDocumentFormattingEditProvider. Exported as public API for consumers that want manual control.

export class NullFormatter implements Disposable {
    register(): void   // no-op if already registered
    unregister(): void // no-op if not registered
    isRegistered(): boolean
    dispose(): void
}

createToolContext wiring (typescript/src/activation.ts)

When isFormatter: true:

  • Calls nullFormatter.register() eagerly at context creation (extension visible in picker from activation)
  • After restartServer() resolves: if client is already State.Running (initial transition is missed by the onDidChangeState listener — same pattern as the existing busy-status reset), disposes the placeholder immediately
  • onDidChangeState listener: Runningunregister(), Stopped/Startingregister() (covers restart cycles)
  • dispose() calls nullFormatter?.dispose()

Tests

Six new tests in activation.test.ts cover: activation-time registration, disposal on State.Running via listener, full Stopped → Starting → Running restart cycle, the already-Running synchronous guard, and isFormatter: false/unset no-ops. Mock client extended with state getter and simulateStateChange().

Version bump

0.6.10.7.0 (new public API: ToolConfig.isFormatter, NullFormatter export).

Copilot AI changed the title [WIP] Add null formatter registration for VS Code extensions feat(common): add NullFormatter + isFormatter to fix duplicate formatter picker entry Jun 17, 2026
Copilot AI requested a review from edvilme June 17, 2026 23:36
Copilot AI changed the title feat(common): add NullFormatter + isFormatter to fix duplicate formatter picker entry Sync pyproject.toml version to 0.7.0 Jun 18, 2026
@edvilme edvilme changed the title Sync pyproject.toml version to 0.7.0 Add lifecycle-aware NullFormatter placeholder for formatter tool extensions Jun 18, 2026
@edvilme edvilme marked this pull request as ready for review June 18, 2026 17:55
@edvilme edvilme added bug Something isn't working debt Code Cleanup, Refactorings and Repo health. Not feature related labels Jun 18, 2026
Comment thread typescript/src/activation.ts
Comment thread typescript/src/activation.ts
Comment thread typescript/tests/activation.test.ts
Comment thread typescript/tests/_languageclient_mock.ts
@rchiodo

rchiodo commented Jun 18, 2026

Copy link
Copy Markdown

Core fix looks good. A few non-blocking concerns worth addressing before merge: the re-registration coverage claimed in the CHANGELOG/JSDoc doesn't fully hold for extension-driven restarts, the failure path can leave the extension with no placeholder, and the new shared-mock additions are unused by the actual test suite.

rchiodo
rchiodo previously approved these changes Jun 18, 2026

@rchiodo rchiodo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via Review Center.

- 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>
Comment thread typescript/src/activation.ts Outdated
Comment thread typescript/tests/activation.test.ts
Comment thread typescript/src/types.ts
@rchiodo

rchiodo commented Jun 18, 2026

Copy link
Copy Markdown

Solid fix that moves the formatter placeholder lifecycle into the shared package. One thing worth a look: the eager register() at the top of runServer() (needed to cover failure paths) can transiently re-introduce the duplicate-formatter window during a healthy restart — guarding it on state !== Running would close that gap. Otherwise the change and tests look good.

rchiodo
rchiodo previously approved these changes Jun 18, 2026

@rchiodo rchiodo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via Review Center.

- 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>
@edvilme edvilme enabled auto-merge (squash) June 18, 2026 20:27
@rchiodo

rchiodo commented Jun 18, 2026

Copy link
Copy Markdown

GitHub cannot anchor PR review comments to unchanged lines in the diff. Falling back to a general PR comment for typescript/src/activation.ts:L188.

📍 typescript/src/activation.ts:184
The catch (ex) block calls nullFormatter?.register() with no guard, unlike the top-of-runServer register you correctly gated on state !== State.Running. The Skeptic flagged this and I verified it against the diff: when a healthy Running client exists and getProjectRoot()/getWorkspaceSettings() throws, this registers the placeholder while the old client is still Running and serving textDocument/formatting → two providers on the same selector (#752). Worse than the prior transient — the failed restart leaves ctx.lsClient unchanged and Running, so the duplicate persists until the next successful restart. Apply the same guard: if (!disposed && nullFormatter && (!ctx.lsClient || ctx.lsClient.state !== State.Running)) nullFormatter.register(); (this also prevents resurrecting the placeholder after dispose() during an in-flight restart).

[verified]

@rchiodo

rchiodo commented Jun 18, 2026

Copy link
Copy Markdown

Solid fix overall, but one blocking issue: the failure-path register() in runServer's catch block lacks the same state !== State.Running guard you correctly applied at the top of the function, which can re-introduce the duplicate-formatter symptom (#752) when a restart fails with a healthy client still running. Please apply the same guard there.

…ailure

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>
@edvilme

edvilme commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Addressed in 4a2bfc2. The catch block's
egister()\ now uses the same !ctx.lsClient || ctx.lsClient.state !== State.Running\ guard, preventing the duplicate-formatter symptom when a restart fails while a healthy client is still running.

@edvilme edvilme requested a review from rchiodo June 18, 2026 23:02
Comment thread typescript/src/activation.ts
@rchiodo

rchiodo commented Jun 18, 2026

Copy link
Copy Markdown

GitHub cannot anchor PR review comments to unchanged lines in the diff. Falling back to a general PR comment for typescript/src/activation.ts:L186.

The empty-interpreter branch (workspaceSetting.interpreter.length === 0) stops the client, nulls ctx.lsClient, disposes serverDisposables, and returns — but unlike the other no-running-client exits it never calls nullFormatter.register(). If a Running formatter has its interpreter cleared, the top-of-runServer guard sees the old client still Running and skips register(), so the placeholder surviving depends on stop() synchronously emitting State.Stopped to the listener. To make this robust, either add the standard guard (if (nullFormatter && (!ctx.lsClient || ctx.lsClient.state !== State.Running)) nullFormatter.register();) to this branch, or add a test that clears the interpreter while Running and asserts the placeholder ends registered.

Comment thread typescript/tests/activation.test.ts
@rchiodo

rchiodo commented Jun 18, 2026

Copy link
Copy Markdown

Solid fix with good test coverage. Two things before merge: remove the inline #752 reference from the code comment (keep it in the CHANGELOG/PR only), and double-check the empty-interpreter restart branch re-registers the placeholder so a Running formatter that loses its interpreter can't disappear from the picker.

… #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>
@edvilme

edvilme commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Addressed in 76e1bad:

  • Removed the inline #752\ reference from the code comment (kept in CHANGELOG/PR only).
  • Added
    ullFormatter?.register()\ in the empty-interpreter branch so a Running formatter that loses its interpreter stays visible in the picker.

@edvilme edvilme requested a review from rchiodo June 18, 2026 23:53
Comment thread typescript/tests/activation.test.ts
@rchiodo

rchiodo commented Jun 19, 2026

Copy link
Copy Markdown

Solid, well-tested fix for the duplicate-formatter regression. A couple of non-blocking notes below on the empty-interpreter exit path and test fidelity; safe to merge once you've had a look.

@rchiodo rchiodo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via Review Center.

@edvilme edvilme merged commit 0258b22 into main Jun 19, 2026
20 checks passed
@edvilme edvilme deleted the copilot/add-null-formatter-registration branch June 19, 2026 00:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working debt Code Cleanup, Refactorings and Repo health. Not feature related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants