Repository navigation
Handle the stop that follows a failed initialization - #1896
Dirk Bäumer (dbaeumer) merged 2 commits into
Conversation
|
@microsoft-github-policy-service agree company="xtclang.org" |
There was a problem hiding this comment.
🔵 Needs a closer look
It modifies the core client lifecycle/async rejection-handling state machine of a widely-depended-upon library, which warrants final human sign-off despite being small and well-tested.
No open issues 🎉
What changed in this PR
This PR fixes an unhandled promise rejection that occurs when a language server refuses initialization. In BaseLanguageClient.initialize, the catch block previously called void this.stop() in three places (when initializationFailedHandler returns false, when the user declines a retry, and in the default case). Because the client is still in ClientState.Starting, the base shutdown rejects with "Client is not running and can't be stopped", and since nothing awaited the promise, VS Code surfaced it as an unhandled error (with telemetry, per #1307). The fix routes all three calls through a new private stopAfterFailedInitialize() helper that still calls stop() (preserving subclass cleanup such as the Node client terminating the server process) but handles the rejection by logging it without a notification — mirroring the existing pattern in handleConnectionError.
Changes:
- Added
stopAfterFailedInitialize()to handlestop()'s rejection after a failed initialization, replacing the threevoid this.stop()calls. - Added a
failInitializeServertest server that refusesinitializebut keeps its connection alive. - Added an integration test asserting
start()rejects,stop()is called once, the client reachesStopped, and no rejection is left unhandled.
| File | Description |
|---|---|
| client/src/common/client.ts | Introduces stopAfterFailedInitialize() and wires it into the three failed-initialization paths so the stop() rejection is logged instead of left unhandled. |
| client-node-tests/src/servers/failInitializeServer.ts | New test server that returns a ResponseError<InitializeError> from onInitialize while keeping the connection open. |
| client-node-tests/src/integration.test.ts | New test verifying a refused initialization stops the client with no unhandled rejection. |
Review effort: Balanced
6c44640 to
da1f565
Compare
When initialization fails, initialize() called `void this.stop()` while the client was still starting. The base shutdown rejects a client that is not running, so that promise rejected with "Client is not running and can't be stopped" and nothing handled it. Handle the rejection, logging it without a notification, as handleConnectionError does for the stop it starts. The client still calls stop(), because the Node client's shutdown override terminates the server process. A new test starts a client against a server that refuses initialization but keeps running, and checks that start() rejects with the server's error, that stop() is still called and the server process is ended, and that no rejection is left unhandled.
da1f565 to
b566bfd
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Although I found no issues, the change affects the client lifecycle/initialization error-handling path, so final human review is warranted before approval.
Wait — let me correct that assessment line to match my actual finding.
No open issues 🎉
Review effort: Balanced
Fixes the "Client is not running and can't be stopped" part of #1307.
Problem
When initialization fails, the
catchinBaseLanguageClient.initializecallsvoid this.stop(): wheninitializationFailedHandlerreturnsfalse, when the user declines a retry, and in the default case. The client is still inClientState.Startingat that point, soshutdownthrowsClient is not running and can't be stopped. It's current state is: starting. Nothing awaits that promise, so the rejection is unhandled. VS Code reports it as an unhandled error (with telemetry, as #1307 describes), and the only way an extension can avoid it is to overridestop()and catch every rejection, which also hides real stop failures.Change
The three calls go through a small
stopAfterFailedInitialize()that still callsstop()but handles its rejection, the same wayhandleConnectionErroralready handles thestop()it starts: logged withthis.error('Stopping server failed', error, false), without a notification.The client still calls
stop(). In my comment on #1307 I suggested releasing the connection without going throughstop(), butstop()is what lets subclasses clean up: the Node client'sshutdownoverride terminates the server process in itsfinally, even though the baseshutdownrefuses a client that is still starting. Skippingstop()would leave a server that refused initialization running.Related issues and pull requests
None of these covers this rejection:
Pending response rejected since connection got disposed. That is the other initialization failure, where the connection closes duringinitialize. Preserve the startup promise when initialization loses its connection #1867 (merged) makes it reach the caller ofstart(), and its regression test stubsstop()precisely because of the separate rejection fixed here.initializeruns.shutdown's promise chain withoutPromise.prototype.finallyforfinallyis not a function error hit with_getServerWorkingDir#1653. It does not change whenstop()is called after a failed initialization or who handles its result. This change does not touchshutdown, so the two do not conflict.stop()as handled #1782 (open) treats a connection close duringstop()as handled, for a client that is already stopping.needsStop()andstop()disagreeing. It is related: a laterstop()ordispose()on a client whose start failed still rejects, because the client is not running. This change leaves that, and the baseshutdown's refusal to stop a starting client, as they are.stderron launch failures #1185 and Allow clients of the LSP library to capture all errors and show customized / localized message. #1262 (open) ask for the server's stderr on launch failures and for customizable error messages; they are about reporting, not this rejection.Tests
A refused initialization stops the client without an unhandled rejection(inServer tests) starts a client against a newfailInitializeServer, which answersinitializewith an error but keeps running, so the client still owns a live connection. It checks thatstart()rejects with the server's error, thatstop()is still called once and the server process is ended (the client reachesStoppedwhen its connection closes), and that no rejection is left unhandled.Validation
On macOS ARM64 with Node.js 24.21.0 and VS Code Insiders 1.143.0 (
a0c652031362):npm run compilepassed.npm --prefix client run lintandnpm --prefix client-node-tests run lintpassed.client-node-testseditor suite passed: 215 tests. Without the change toclient.ts, only the new test fails, with the unhandledClient is not running and can't be stopped. It's current state is: startingrejection; unchangedmainpasses its 214.git diff --checkpassed.