Repository navigation
Don't rely on Promise.prototype.finally when starting or stopping the client - #1895
Open
Yashvardhan Thanvi (Thanatos9404) wants to merge 1 commit into
Open
Yashvardhan Thanvi (Thanatos9404) wants to merge 1 commit into
Yashvardhan Thanvi (Thanatos9404) wants to merge 1 commit into
Conversation
… client createMessageTransports and shutdown start their promise chains with promises created through the global Promise (new Promise, Promise.resolve, Promise.race) and end them with .finally(). All extensions in an extension host share one global object, so if any code replaces globalThis.Promise with an implementation that predates ES2018, .then() returns an object without finally and the call throws synchronously: TypeError: this._getServerWorkingDir(...).then(...).finally is not a function Rewrite both places with async/await and try/finally. await accepts any thenable and an async function always returns a native promise, so the code no longer depends on what the global Promise provides. Error propagation, cleanup order and logging are unchanged. Add integration tests that start and stop a client while the global Promise is temporarily replaced by an implementation without finally. Fixes microsoft#1653
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1653
Why
finallyis missingDirk Bäumer (@dbaeumer) asked why this happens before changing the code, so here is what I found.
The value that has no
finallyis the result of.then(...)increateMessageTransports. With an unmodified native promise that can't happen.Promise.prototype.thenalways builds its result through the native Promise species, so nothing the callback returns can change it. So either_getServerWorkingDirreturns something that is not a native promise, or something in the extension host patched the native prototype (deletingPromise.prototype.finally, or wrapping.thenthe way zone.js does). The second would also break.finallyin VS Code's own code and atnode/main.ts:253, which makes the first much more likely._getServerWorkingDircreates its promise withnew Promise(...)orPromise.resolve(undefined). Those use the globalPromiseand look it up when the code runs. Every extension in an extension host shares one global object. So if any code in that process setsglobalThis.Promiseto an older implementation (a pre ES2018 polyfill, or a library that installs its own Promise globally), the client's promise comes from that implementation. Itsthenreturns an object withoutfinally, and.finally(...)throws.Running that case in plain Node gives exactly the message from the telemetry:
This also fits the rest of the report:
.finallyis being called, which is why the stack in the issue only hascreateMessageTransports,createConnectionandstartand no async frames.~/.vscodewhere extensions live.What I can't tell you is which extension or library swaps the global
Promisefor the affected users. The telemetry doesn't say. Everything above points at another extension in the same extension host, but I haven't pinned it to one.There is a side effect on the current code too. The
.thencallback is already registered when.finallythrows, so the server process still gets spawned afterstart()has failed. That process has no connection and no exit listener.What changes
client/src/node/main.ts:createMessageTransportsis nowasync. The old.thencallback is a localstartServerfunction, kept line for line. The.finallyis nowtry { return await startServer(await this._getServerWorkingDir(...)); } finally { ... }. Thereturn awaitmatters: the exit listener is still attached only after the transports are ready (for example afterwaitForConnectionon pipe and socket), just as before.client/src/common/client.ts:shutdownhas the same problem.Promise.race([tp, shutdown]).then(...).finally(...)goes through the globalPromise, sostop()fails withPromise.race(...).then(...).finally is not a functionand the client is left in the stopping state. With the start fix alone, affected clients would now get far enough to hit this on stop, so I changed it the same way: an async IIFE with try/catch around the race and the same cleanup infinally. As before, the "Stopping server failed" log only covers a rejected race, and the timeout error is thrown without that log.awaitaccepts any thenable, and anasyncfunction always returns a native promise, so neither path depends on the globalPromiseany more. Error propagation, cleanup order and log messages stay the same. One visible difference: becausecreateMessageTransportsis nowasync, a synchronous throw inside it (for example from a throwingserver()callback) becomes a rejected promise.start()is unaffected sincecreateConnectionawaits it.I looked at the other
.finallycalls inclient/src.node/main.tsshutdowncalls it onsuper.shutdown(),common/client.tscalls it onthis._p2c.asDiagnostics(), andcommon/diagnostic.tscalls it onpullAsync(). All three areasyncfunctions, so they always return native promises and are not affected.Tests
I added two tests to the
Server testssuite inclient-node-tests/src/integration.test.ts. One starts a client and the other stops one, each while the globalPromiseis replaced with a small then-only implementation (PromiseWithoutFinally). The replacement only lasts for the synchronous part of thestart()/stop()call, and the nativePromiseis put back right after, so nothing else in the test host is affected.this._getServerWorkingDir(...).then(...).finally is not a functionand the stop test fails withPromise.race(...).then(...).finally is not a function.Also ran:
npm run compile,npm run compile:client-node-tests, andnpm --prefix client run lint/npm --prefix client-node-tests run lint, all clean. Lint prints the existing@stylisticdeprecation warning and nothing else.