Repository navigation
Prevent duplicate Quarto groups in the Outline when settings change quickly - #1210
Conversation
vezwork
left a comment
There was a problem hiding this comment.
Requested some changes to improve readability/reasonability.
This seems like an important fix, I wonder if we shouldn't be doing something like this in more places?
| let queue: Promise<void> = Promise.resolve(); | ||
| let registration: Disposable | undefined; | ||
| let registeredFor: boolean | undefined; | ||
|
|
||
| return { | ||
| update(showCodeCellsInOutline: boolean) { | ||
| queue = queue.then(async () => { | ||
| if (registration && registeredFor === showCodeCellsInOutline) { | ||
| return; | ||
| } | ||
| registration?.dispose(); | ||
| registration = undefined; | ||
| registration = await register(showCodeCellsInOutline); | ||
| registeredFor = showCodeCellsInOutline; | ||
| }).catch(onError); | ||
| return queue; | ||
| } | ||
| }; |
There was a problem hiding this comment.
| let queue: Promise<void> = Promise.resolve(); | |
| let registration: Disposable | undefined; | |
| let registeredFor: boolean | undefined; | |
| return { | |
| update(showCodeCellsInOutline: boolean) { | |
| queue = queue.then(async () => { | |
| if (registration && registeredFor === showCodeCellsInOutline) { | |
| return; | |
| } | |
| registration?.dispose(); | |
| registration = undefined; | |
| registration = await register(showCodeCellsInOutline); | |
| registeredFor = showCodeCellsInOutline; | |
| }).catch(onError); | |
| return queue; | |
| } | |
| }; | |
| let previous?: {registration: Promise<Disposable>, showCodeCellsInOutline: boolean} | |
| return { | |
| async update(showCodeCellsInOutline: boolean) { | |
| try { | |
| const disposableReg = await previous?.registration | |
| if (disposableReg !== undefined) { | |
| if (previous.showCodeCellsInOutline === showCodeCellsInOutline) { | |
| return disposableReg | |
| } else { | |
| disposableReg.dispose() | |
| } | |
| } | |
| previous = { | |
| registration: register(showCodeCellsInOutline), | |
| showCodeCellsInOutline | |
| } | |
| } catch(e) { onError(e) } | |
| return await previous.registration | |
| } | |
| }; |
Not sure this code is quite right (should be close tho), I quickly hand wrote it and didn't test it. But I prefer something like this. Endlessly chaining promises and three separate mutable variables is hard to reason about.
There was a problem hiding this comment.
Oh I would need to take previous.registeredFor into account, I missed that. Give me a sec I'll update my suggestion.
There was a problem hiding this comment.
ok updated. It can probably be slightly improved still cuz I don't think disposableReg will ever be undefined if previous isn't also?
There was a problem hiding this comment.
Thank you for the suggestion! I agree that one state object is much easier to read than three variables and a queue. I changed the registrar to keep one current promise. This promise holds the registration and its showCodeCellsInOutline value together, like your previous object.
I could not use your version exactly, though, because of the order of operations. Each call awaits previous.registration before it replaces previous. When two calls overlap, both get the same registration, both dispose it, and both register a new one. The second assignment then overwrites the first new registration, which is the leak from #1209. To prevent this, each update now replaces current synchronously, before any await, and then each update always waits for the update before it.
You were correct that disposableReg is only undefined when previous is also undefined! With a single Registration | undefined, that extra check is not necessary.
vezwork
left a comment
There was a problem hiding this comment.
Nice. Approved with a nitpick readability suggestion
| current = (async () => { | ||
| const registration = await previous; | ||
| if (registration?.showCodeCellsInOutline === showCodeCellsInOutline) { | ||
| return registration; | ||
| } | ||
| try { | ||
| registration?.disposable.dispose(); | ||
| return { | ||
| disposable: await register(showCodeCellsInOutline), | ||
| showCodeCellsInOutline | ||
| }; | ||
| } catch (error) { | ||
| // Leave nothing registered so the next update retries. | ||
| onError(error); | ||
| return undefined; | ||
| } | ||
| })(); |
There was a problem hiding this comment.
nitpick: pull this immediately invoked function out of documentSymbolRegistrar, give it a name, and pass it previous.
|
Also, do you suspect this problem pattern of overlapping registrations could be happening elsewhere where registrations happen in response to config changes @juliasilge? Or would it just be isolated to this for some reason I don't see? |
|
I am pretty sure that this problem is isolated, @vezwork. The leak needs two conditions together:
The symbol provider is the only place with both. I looked at each config-change handler in
A sort of related item, but not a leak is that |
|
TY nice fix! |
Fixes #1209
Problem
The Outline view can show more than one "Quarto" group for one document. We have had some reports of this in Positron too here and there, but without a firm repro. Seems like this problem can occur when settings change quickly, for example while VS Code starts.
The LSP registers its document symbol provider again each time the configuration changes. Each update disposes the old registration and then waits for a new one. If two updates overlap, both updates dispose the same old registration. Then the second assignment overwrites the first new registration, so the server cannot dispose it later. Each leaked registration adds one more "Quarto" group to the Outline.
Changes
documentSymbolRegistrarinapps/lsp/src/symbol-registration.tsputs updates in a queue, so only one update runs at a time. As a result, at most one registration is active, and the last value ofshowCodeCellsInOutlinesets the final state.showCodeCellsInOutlinedid not change, the registrar does not register again. The client sends configuration and theme notifications for any setting change, so this skip also stops Outline refreshes that are not necessary.mdLsReadydid not resolve, and symbol and folding requests did not complete.apps/lspnow has unit tests for overlapping updates, fast toggles, the skip, and recovery after a failed registration. The overlap and toggle tests fail against the previous code.npm run test-packagesnow includesquarto-lsp.How to test
.qmdfile that has headings and code cells.