Skip to content

Fix multiple isort handlers registered due to concurrent server restarts#541

Draft
Copilot wants to merge 8 commits intomainfrom
copilot/investigate-issue-463
Draft

Fix multiple isort handlers registered due to concurrent server restarts#541
Copilot wants to merge 8 commits intomainfrom
copilot/investigate-issue-463

Conversation

Copy link
Contributor

Copilot AI commented Feb 25, 2026

  • Investigate root cause of multiple isort handlers (issue Multiple isort handlers getting registered #463)
  • Re-added serverEnabled support in extension.ts with proper mode cleanup
  • Fixed subscription accumulation bug: track sortFeaturesDisposable at module level
  • Unified cleanup: unRegisterSortImportFeatures() called explicitly in both branches
  • Removed redundant sortFeaturesDisposable = undefined assignment before immediate reassignment
  • Fix subscription accumulation: splice old sortFeaturesDisposable out of context.subscriptions in both branches
  • Ensure LSP server is stopped when switching to server-less mode
  • Fix missing state cleanup in server-enabled branch: splice + set sortFeaturesDisposable = undefined to prevent stale reference on repeated mode toggles
  • Document isRestarting guard protecting against orphaned startup() calls
  • Fixed curly lint warnings in if (idx >= 0) guards

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

Co-authored-by: bschnurr <1946977+bschnurr@users.noreply.github.com>
Copilot AI changed the title [WIP] Investigate issue with isort in VSCode Fix multiple isort handlers registered due to concurrent server restarts Feb 25, 2026
context.subscriptions.push(sortFeatures);
await sortFeatures.startup();
}
} finally {
Copy link

Choose a reason for hiding this comment

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

@copilot -78
[High] Subscription accumulation bug: Each call to registerSortImportFeatures() pushes to context.subscriptions, but repeated mode toggles will accumulate entries. Store sortFeatures in a module-level variable (like lsClient) and dispose it explicitly before re-registering:
let sortFeaturesDisposable: Disposable | undefined;
// ...
if (sortFeaturesDisposable) {
sortFeaturesDisposable.dispose();
}
sortFeaturesDisposable = registerSortImportFeatures(serverId);
context.subscriptions.push(sortFeaturesDisposable);

Both Skeptic and Architect flagged this independently.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in eb9e5f1. sortFeaturesDisposable is now a module-level variable typed as (vscode.Disposable & { startup: () => Promise<void> }) | undefined. Before each re-registration it's explicitly disposed, preventing the accumulation of stale entries in context.subscriptions.

`[Option 2] Set an interpreter using "${serverId}.interpreter" setting.\r\n`,
`Please use Python ${PYTHON_VERSION} or greater.`,
);
if (getServerEnabled(serverId)) {
Copy link

Choose a reason for hiding this comment

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

@copilot -79
[Medium] Asymmetric cleanup patterns: Server mode uses explicit unRegisterSortImportFeatures() cleanup, while server-less mode relies on implicit subscription disposal. Consider unifying: call unRegisterSortImportFeatures() in BOTH branches before their respective setup, ensuring symmetric cleanup regardless of previous mode.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in cfdb13a. Both branches now call unRegisterSortImportFeatures() explicitly before their respective setup. The server-less branch replaces the conditional sortFeaturesDisposable.dispose() (which only cleaned up when the variable was already set) with a direct unRegisterSortImportFeatures() call, matching the server-mode branch exactly.

Comment on lines +65 to +72
if (lsClient) {
try {
await lsClient.stop();
} catch (ex) {
traceError(`Server: Stop failed: ${ex}`);
}
lsClient = undefined;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

@copilot Why not use the existing deactivate method, and have it catch too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

deactivate() is only invoked by VS Code when the extension is fully torn down — it can't be reused for mid-lifecycle cleanup during a runServer() call triggered by a serverEnabled toggle while the extension is still active. The lsClient.stop() here stops a running LSP server so the server-less fallback providers can take over, which has to happen inline within runServer().

@edvilme edvilme added the bug Issue identified by VS Code Team member as probable bug label Feb 25, 2026
… level

Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com>
…e branches

Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com>
src/extension.ts Outdated
Comment on lines 75 to 76
sortFeaturesDisposable = undefined;
sortFeaturesDisposable = registerSortImportFeatures(serverId);
Copy link
Contributor

Choose a reason for hiding this comment

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

@copilot The undefined assignment is redundant

Suggested change
sortFeaturesDisposable = undefined;
sortFeaturesDisposable = registerSortImportFeatures(serverId);
sortFeaturesDisposable = registerSortImportFeatures(serverId);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed in 35ce07d.

Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com>
edvilme
edvilme previously approved these changes Feb 25, 2026
unRegisterSortImportFeatures();
sortFeaturesDisposable = registerSortImportFeatures(serverId);
context.subscriptions.push(sortFeaturesDisposable);
await sortFeaturesDisposable.startup();
Copy link

Choose a reason for hiding this comment

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

@copilot -80
[High] Subscription accumulation still occurs. The Copilot response claimed disposal was added, but the diff shows no sortFeaturesDisposable.dispose() call before re-assignment. Each true→false toggle pushes a new entry to context.subscriptions without removing the previous one.
Suggested fix (choose one):
// Option A: Dispose and remove from subscriptions
unRegisterSortImportFeatures();
if (sortFeaturesDisposable) {
const idx = context.subscriptions.indexOf(sortFeaturesDisposable);
if (idx >= 0) context.subscriptions.splice(idx, 1);
sortFeaturesDisposable.dispose();
}
sortFeaturesDisposable = registerSortImportFeatures(serverId);
context.subscriptions.push(sortFeaturesDisposable);

// Option B: Only push once, manage lifecycle via module variable
// (Don't push on repeated calls; dispose in deactivate())

Both Skeptic and Architect verified this is the same bug reported in the prior review.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4deee5e. Before reassigning sortFeaturesDisposable, the old entry is now spliced out of context.subscriptions (if present), preventing accumulation on repeated true→false toggles. The new entry is then pushed as usual.

… context.subscriptions before re-registering

Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com>
`[Option 2] Set an interpreter using "${serverId}.interpreter" setting.\r\n`,
`Please use Python ${PYTHON_VERSION} or greater.`,
);
if (getServerEnabled(serverId)) {
Copy link

Choose a reason for hiding this comment

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

@copilot Missing state cleanup in server-enabled branch. When switching to server mode, unRegisterSortImportFeatures() is called but sortFeaturesDisposable is never set to undefined. If the user toggles server-less → server → server-less, the stale reference will still exist, and the indexOf check on line 74 will look for an already-disposed object.
Add after line 47:
if (sortFeaturesDisposable) {
const idx = context.subscriptions.indexOf(sortFeaturesDisposable);
if (idx >= 0) context.subscriptions.splice(idx, 1);
sortFeaturesDisposable = undefined;
}

Alternatively, extract this cleanup into a helper to avoid the asymmetry both branches exhibit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 884ff55. The server-enabled branch now splices sortFeaturesDisposable from context.subscriptions and sets it to undefined immediately after unRegisterSortImportFeatures(), so no stale reference persists across a server-less → server → server-less toggle.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d233761. The server-enabled branch now splices the old sortFeaturesDisposable out of context.subscriptions and sets it to undefined, so a subsequent server-less→server→server-less toggle won't find a stale already-disposed reference.

const idx = context.subscriptions.indexOf(sortFeaturesDisposable);
if (idx >= 0) context.subscriptions.splice(idx, 1);
}
sortFeaturesDisposable = registerSortImportFeatures(serverId);
Copy link

Choose a reason for hiding this comment

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

@copilot Potential orphaned startup. If runServer() is called again while startup() is still awaiting (e.g., rapid config toggles), the previous disposable is disposed but its in-flight startup() may still be running. Consider either:

Storing the startup promise and awaiting/canceling it before disposing, or
Adding a comment documenting that startup() must be resilient to its disposable being disposed mid-flight.

The isRestarting guard mitigates this by returning early on concurrent calls, so this is a minor concern.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a comment in 884ff55 documenting that the isRestarting flag prevents concurrent runServer() calls, so a re-entrant dispose cannot race with an in-flight startup().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a comment in d233761 documenting that isRestarting prevents concurrent runServer() calls, so startup() cannot be orphaned by a re-entrant dispose while it is still awaiting.

…tarting guard for startup()

Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com>
Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Issue identified by VS Code Team member as probable bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants