VSCode Bug Tracking (#142109)

Discovery
This bug starts out like most bugs do - I came across it by accident, didn't understand it at first, and it consumed an entire day where I got very little done because of it.
The initial work wasn't anything special: switching from writing stylesheets in .css
files to using .scss
files. The CI pipeline was updated for the new addition and worked well, all that was left to do was make the switch in the source. To get things kicked off I moved the import of Bootstrap's stylesheets from the standard <head>
import to a @use
rule as seen in the header image above. Everything worked fine, and it was time to move on the next step - the old .css
file was removed from the repository and its contents dumped into the .scss
file to make sure the reference to the new compiled file was correct in the <head>
element of the page. Still, everything was working correctly.
With the new ability to write using the expanded features of Sass, it was time to get to work - time to add new rules between the variables and Bootstrap import and the existing CSS rules. Changing the colours of all the Bootstrap defaults hadn't gone perfectly and it was time to override a value or two. Cursor in place, first character of the new rule is pressed - and VSCode is no longer responding to keyboard input.
Strange.
Thankfully reloading the window still works. Window reloaded, second character of the new rule is typed - and VSCode is no longer responding to keyboard input.
This workflow isn't going to be fun for very long.
It's important to note that in this synchronized VSCode profile doesn't touch a lot of .scss
files, so it makes sense that there's some sort of extension that's causing issues somewhere along the line. It's always the fault of the extensions. No big deal, close down VSCode and load it back up again with the --disable-extensions
argument. Create a new file, select the language, start typing, and everything works fine. Well that solves it, it's an extension.
It wasn't an extension. *
What followed was a good 7 hours of struggling to be productive without fully understanding the problem, and a mix of trying to troubleshoot the problem as my frustration at being unable to be productive went on. Saving 7 hours of frustration, my understanding of the issue after my day was done meant the following conditions had to be met for it to occur:
- The file must be a
.scss
file - The file must be longer than 1000 lines
- The first line of the file must be a comment using the
//
notation
The content of the file does not matter aside from the above restrictions.
For the sake of ease, you can find an example file in a repository over here.
When these 4 conditions are met anything that triggers the autocomplete window to appear will instead lock up and prevent extensions from running. For me, this means no keyboard functionality due to using the VSCodeVim plugin.
This was repeatable for me on 3 different setups, and I had two friends verify it on their setups as well. Interestingly a friend-of-a-friend was unable to replicate it, but I didn't pay that too much thought. I now have an idea of what I think would cause that, but it isn't important.
Willing to do my part, I headed over to the VSCode repository to look for an existing issue. If I'm having problems with VSCode becoming non-responsive in SCSS files I'd think it's a pretty big deal. Searching for SCSS
in the issue list I went back a month looking for a related issue, and then glanced at any issue that had a handful of comments on it in case it was a particularly tricky issue to catch. Neither turned up any matches which I thought was weird, so I threw together an issue and ended my day. Hopefully it'll be a quick turnaround and I'll have no further excuses for not being productive.

First of all, I don't envy the team behind the VSCode issue list at all. At the time of writing there's 5,681 open issues, and they've closed off 125,239. That is an incredible amount of issues to filter through. They do an incredible job keeping a handle on it as they do, and I apologize for contributing to the clutter.
I checked back a month for SCSS issues though, how did I miss an issue that matched mine?
Turns out the issue was reported about a month and a half ago, longer than the 1 month cutoff I checked through. My fault, I should have taken more time to go through the list. One thing stuck out to me from the original issue however, and it made me realize there wasn't going to be a quick conclusion to this issue.

I can appreciate that unlike CSS files, it's much easier to avoid giant SCSS files thanks to the @use
and @forward
(and @import
before it) rules. Still though, the issue has existed for over a year and was acknowledged a month and a half ago. Thankfully, all of the conditions above have to be met, and they're quite easy to break.

A simple workaround restores functionality to VSCode and its extensions, and the rest of my work week was back to full speed ahead. A strange and annoying issue, but fortunately one with a simple solution.
Identifying the Culprit
Unfortunately, this issue has all the makings of one that sticks in my head. It was annoying, it took me hours to solve, and it had a simple solution that worked around the issue, but didn't resolve the issue at its core.
Now I am neither a true JavaScript developer, or at all familiar with the VSCode codebase. This seemed like a great chance to dive in and get my hands dirty, maybe learn something in the process, and have a remote chance at solving this issue that only myself and 1 other person had ever encountered. Exactly the kind of project that makes me drop every other project in my life on a weekend and fully engross myself in a new one.
First of all, VSCode has a very straightforward How to Contribute page for getting started. Unfortunately they're missing a note on a couple dependencies that Linux users on a basic Ubuntu install are going to be missing, but that's nothing a bit of prowess with a search engine can't solve. Everything you need to get started with debugging the latest version of VSCode is there and running it through your existing VSCode makes it a natural experience.
Unfortunately having a version of the source code up in front of you does exactly nothing to help you pinpoint the cause of the problem. A bit of head scratching and light searching later, I came across an Open Process Monitor under the Help menu option, which is a great place to get started.

This above image shows that something in the extensionHost
is running along at 100% of a single CPU core (my daily driver for VSCode is a 4 core, 8 thread processor). Weird, I tested without extensions.

Remember this line from earlier in this article?

Saving you some more head scratching and searching, it is an extension. It's an extension that still runs when you use the --disable-extensions
argument.
Inside the extensions panel of your VSCode installation, type the @
character into the search window. This lets you filter them in a couple different ways, but what we're interested in here is the @builtin
tag.

VSCode comes with a handful of its own extensions baked in that you have enabled by default. Importantly, these extensions are not disabled by the disable-extensions
flag. I guess the logic behind it is that these extensions are maintained and developed as a part of the VSCode package, so they're not thought of as a separate issue when reporting a bug. One of these extensions is Emmet
, which enables the use of Emmet abbreviations within VSCode. If you've ever been typing along in an html
document and started a new file with !
to generate a basic document structure, then you've used the Emmet
extension.

@builtin
tag.Cutting to the chase, we care about this because disabling the Emmet extension fixes the issue. The issue looks to be entirely contained within the Emmet extension.
Diving In
Extensions are contained in their own directories in the VSCode repository. For example, we're only going to care about the extensions/emmet
directory. Once again, the VSCode project is well designed, and you can focus your testing on a specific extension by running a debug task while having the directory open in VSCode.
Most of the "happening" within the Emmet extension occurs in util.ts
, so that's a logical place to start debugging. Eventually some "debugging" (really flailing breakpoints about and triggering the issue) narrowed the problem down to somewhere inside the parsePartialStylesheet()
function. Jumping around slightly, we can confirm that this makes sense by searching the directory for parsePartialStylesheet
which turns up the following code block:
if (isStyleSheet(document.languageId) && context.triggerKind !== vscode.CompletionTriggerKind.TriggerForIncompleteCompletions) {
validateLocation = true;
let usePartialParsing = vscode.workspace.getConfiguration('emmet')['optimizeStylesheetParsing'] === true;
rootNode = usePartialParsing && document.lineCount > 1000 ? parsePartialStylesheet(document, position) : <Stylesheet>getRootNode(document, true);
if (!rootNode) {
return;
}
currentNode = getFlatNode(rootNode, offset, true);
}
Lines 137 - 144 of extensions/emmet/src/defaultCompletionProvider.ts in VSCode 1.65.2
Particularly, we care about the following ternary expression:
rootNode = usePartialParsing && document.lineCount > 1000 ? parsePartialStylesheet(document, position) : <Stylesheet>getRootNode(document, true);
Most of the content here doesn't matter, except for the condition. To put it in mostly English, if document.lineCount
is greater than 1000 we return parsePartialStylesheet(document, position)
, otherwise we do something different. We're pretty sure at this point the issue is inside of parsePartialSheet()
and this confirms it even further: one of our original criteria for triggering the issue was the file had to be longer than 1000 lines.
Okay, but what is `parsePartialStylesheet()`?
One of the most time consuming parts of debugging VSCode is this question. In short, VSCode doesn't use JSDoc. There is no available documentation at the class or function level. There is some documentation for their API, but that's of little to no use here. This isn't a terribly annoying issue, since all the code inside util.ts
uses a decent naming scheme that for the most part describes what it is. Unfortunately, many of the functions in this file reference other sections of the VSCode source, such as the file that is bundled inside of the VSCode Electron application, extensionHostProcess.js
. If you're using the debugger to step through an extension, you're going to get to know this file quite well. As you're probably expecting, this file is minified. 108 lines, and at it's widest point 162,664 characters wide. You should absolutely make use of VSCode's ability to "pretty print" minimized files. Now your only issue is dealing with everyone's favourite variable names, like a
, Q
, and m
.
Keeping on topic, parsePartialStylesheet()
is the function used to parse part of a stylesheet (declared by the extension as a file that ends in .css
, .sass
, .scss
, .less
, .sss
, or .stylus
. As we recognized earlier, it is used to parse the current file when the file is more than 1000 lines of code (presumably for performance considerations, as this is done on every keystroke).
At this point I confirmed that .sass
, .scss
, and .less
are all susceptible to this issue. .css
is not as it doesn't recognize lines starting with //
as a comment, and the default VSCode configuration does not invoke the Emmet extension for .sss
or .stylus
files.
So that leaves two requirements left to figure out:
- The file must be a
.sass
,.scss
, or.less
file - The first line of the file must be a comment using the
//
notation
Knowing that .sass
, .scss
, and .less
are all supersets of the CSS standard it makes sense that there will be some generalized code that handles all of them, and that's a good place to start looking. Indeed, the first line inside of the parsePartialStylesheet()
function looks like:
const isCSS = document.languageId === 'css';
Line 190 of src/util.ts
and isCSS
is used in 3 different conditions throughout the file. So we have 3 new breakpoints to focus in on, and see the different in execution between .css
and .scss
files.
Skipping over some time of stepping through lines of JavaScript one at a time, I want to focus in on the following block of code that .scss
files get seemingly stuck in, and .css
files do not:
stream.pos = positionOffset;
let openBracesToFind = 1;
let currentLine = position.line;
let exit = false;
// Go back until we found an opening brace. If we find a closing one, consume its pair and continue.
while (!exit && openBracesToFind > 0 && !stream.sof()) {
consumeLineCommentBackwards();
switch (stream.backUp(1)) {
case openBrace:
openBracesToFind--;
break;
case closeBrace:
if (isCSS) {
stream.next();
startOffset = stream.pos;
exit = true;
}
else {
openBracesToFind++;
}
break;
case slash:
consumeBlockCommentBackwards();
break;
default:
break;
}
if (position.line - document.positionAt(stream.pos).line > 100
|| stream.pos <= limitOffset) {
exit = true;
}
}
I called this the "backwards consume" loop. To give you a really high level overview of the work being done here the partial section of the file is scanned from your cursor to the end (reading in or "consuming" declarations in your stylesheet), then from your cursor to the beginning (hence "backwards consume") looking for a lone bracket that suggests you're currently inside of a declaration. You'd be in the right mindset to think the if (isCSS) {
is suspicious in the above loop, but that's not it.
Practical Example
First, focus on the stream
object. This represents a DocumentStream
object, basically a copy of the file with a cursor used to read the file one line or character at a time. Importantly, stream.backUp(1)
moves the cursor 1 position towards the start of the file, and stream.next()
moves the cursor 1 position towards the end of the file. stream.pos
gets the current position of the cursor (as a single number, the number of characters from the start of the file). Lastly, stream.peek()
returns the character at the current position.
Secondly, know that limitOffset
is the upper limit of the "partial" stylesheet we're looking at. If you're towards the start of the file limitOffset
will be set to 0, which is the start of the file. For all of my testing I stayed towards the start of the file so that I could think of this as always being 0. It also suggests that with a long enough file, you could make changes towards the end of it without running into this issue.
With this in mind, focus on this case in the switch statement:
while (!exit && openBracesToFind > 0 && !stream.sof()) {
consumeLineCommentBackwards();
switch (stream.backUp(1)) {
.
.
.
case slash:
consumeBlockCommentBackwards();
.
.
.
default:
break;
}
if (position.line - document.positionAt(stream.pos).line > 100
|| stream.pos <= limitOffset) {
exit = true;
}
}
}
Moving backwards, every time we encounter the /
character we call consumeBlockCommentsBackwards()
.
function consumeBlockCommentBackwards() {
if (stream.peek() === slash) {
if (stream.backUp(1) === star) {
stream.pos = findOpeningCommentBeforePosition(stream.pos) ?? startOffset;
} else {
stream.next();
}
}
}
Consider the following:
/* Make the links red */
a {
color: #FF0000;
}
The a
on the second line is the 25th character (the "first" character is at position 0). So starting from there, the above would look like:
stream.backUp(1)
moves the pointer to character 24 (the \n
at the end of the first line)
\n
doesn't match any of the cases in the loop.
stream.pos() <= limitOffset
( 24 <= 0
) returns false
and the loop continues.
stream.backUp(1)
moves the pointer to character 23, the /
at the end of the first line.
/
matches the slash
case, as slash
is defined elsewhere in the script.
consumeBLockCommentsBackwards()
is called.
stream.peek() === slash
returns true, as the pointer is still at character 23.
stream.backUp(1) === star
moves the pointer to character 22, the *
at the end of the first line. As a result, the comparison returns true. ( star
is set to the *
character elsewhere in the script)
findOpeningCommentBeforePosition(stream.pos)
moves the pointer to the position of the matching /*
for the */
we found while moving backwards. Pointer is now at character 0.
stream.pos() <= limitOffset
( 0 <= 0
) returns true, and exit
is set to true
.
The loop ends, and execution continues.
And that's what happens when stepping through a .css
file that meets the conditions for our issue. Now lets try it again with our example .scss
file.
The relevant sections of code again:
function consumeBlockCommentBackwards() {
if (stream.peek() === slash) {
if (stream.backUp(1) === star) {
stream.pos = findOpeningCommentBeforePosition(stream.pos) ?? startOffset;
} else {
stream.next();
}
}
}
while (!exit && openBracesToFind > 0 && !stream.sof()) {
consumeLineCommentBackwards();
switch (stream.backUp(1)) {
.
.
.
case slash:
consumeBlockCommentBackwards();
.
.
.
default:
break;
}
if (position.line - document.positionAt(stream.pos).line > 100
|| stream.pos <= limitOffset) {
exit = true;
}
}
}
Our file:
// Make the links red
a {
color: #FF0000;
}
This time, lets start at position 3, the "M" in the first line.
stream.backUp(1)
moves the pointer to character 2, the first space
in the first line.
space
does not match any of the cases in the loop.
stream.pos() <= limitOffset
( 2 <= 0
) returns returns false
and the loop continues.
stream.backUp(1)
moves the pointer to character 1, the second /
in the first line.
/
does match a case in the loop, and consumeBlockCommentBackwards()
is called.
stream.peek() === slash
returns true, as we're currently looking at a /
character.
stream.backUp(1) === star
moves the pointer to character 0, which is the first /
and not star
.
stream.next()
moves the pointer to character 1 and we leave consumeBlockCommentBackwards()
.
stream.pos() <= limitOffset
( 1 <= 0
) returns false
and the loop continues.
stream.backup(1)
moves the pointer to character 0, the first /
in the first line.
/
does match a case in the loop, and consumeBlockCommentsBackwards()
is called.
stream.peek() === slash
returns true, as we're currently looking at a /
character.
stream.backUp(1) === star
tries to move the pointer to -1, which is an invalid position. The function accounts for this, and sets the pointer to the start of the file instead, 0.
stream.next()
moves the pointer to character 1 and we leave consumeBlockCommentBackwards()
.
stream.pos() <= limitOffset
( 1 <= 0
) returns false
and the loop continues.
The last 5 steps are italicized as they repeat infinitely at this point. This causes the process that the extension runs in to run as fast as possible (limited to the speed of 1 CPU core) and prevents any other extension from being able to access resources.
Conclusion
So consumeBlockCommentsBackwards()
is the problem! We've finally arrived at our destination. I had no idea what I was getting myself into when I started this, and it ended up being a relatively small change.
The original consumeBlockCommentsBackwards()
again:
function consumeBlockCommentBackwards() {
if (stream.peek() === slash) {
if (stream.backUp(1) === star) {
stream.pos = findOpeningCommentBeforePosition(stream.pos) ?? startOffset;
} else {
stream.next();
}
}
}
The new consumeBlockCommentsBackwards()
as proposed in pull request #146121
function consumeBlockCommentBackwards() {
if (stream.peek() === slash) {
switch (stream.backUp(1)) {
case star:
stream.pos = findOpeningCommentBeforePosition(stream.pos) ?? startOffset;
break;
case slash:
break;
default:
stream.next();
}
}
}
Give it a try - either go back over the steps above with the new consumeBlockCommentBackwards()
in place, patch your own copy of VSCode by replacing the function inside of extensions/emmet/src/util.ts
, or clone the branch used as the source for the pull request over here.
For now, I've managed to flail my way about the VSCode source code and fix an issue that's gone undetected for close to a year, and caused me a day of pain in my developing life. With some luck, you'll see the changes included in the 1.67.0
release.
Update (04/04/22)
During review of the PR by Microsoft, it was brought up that if the issue was only possible on line 1:1 it would be better to check for that condition than to skip over the second slash.
Initially when I was working on the issue I believed that the issue would be present any time you were editing a line that was exactly 1000 lines below a line that was a comment starting with //
. However, while working on the issue I discovered that wasn't true, due to stream.backUp(1)
only "bouncing" off the start of the file. With this in mind checking for the start of file condition is indeed the better fix, and a few days later I modified the PR to check for that condition instead. consumeBlockCommentsBackwards()
now looks like:
function consumeBlockCommentBackwards() {
if (stream.peek() === slash) {
if (!stream.sof() && stream.peek() === slash) {
if (stream.backUp(1) === star) {
stream.pos = findOpeningCommentBeforePosition(stream.pos) ?? startOffset;
} else {
stream.next();
}
}
}
The addition of !stream.sof() &&
on line 2 being the new suggested change.
With that change in place, the PR has been accepted and merged as of a few minutes ago. As of the next release this bug should no longer exist.