Skip to content

fix: slow saves from runaway flow analysis and full workspace re-checks - #24

Merged
ChatDisabled merged 3 commits into
Qbox-project:mainfrom
HeyyCzer:fix/slow-saves
Nov 1, 2026
Merged

ChatDisabled merged 3 commits into
Qbox-project:mainfrom
HeyyCzer:fix/slow-saves

Conversation

@HeyyCzer

@HeyyCzer HeyyCzer commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Changes

After a few updates, saving Lua files in VS Code started to hang for me on a large server workspace (~800 Lua files, 146 resources). VS Code waits for codeActionsOnSave (I have source.fixAll / source.organizeImports enabled globally), and since the server is single-threaded the request sat in the queue behind work the server was already doing. There were two causes:

1. Flow analysis blew up on locals that walk nested tables (narrow.rs)

Code that reuses one local to step through nested tables, like reading a config or walking a tree:

local node = Config
node = node.Jobs
node = node.police
node = node.grades

Each of these assignments copied every alias the local already had, extended by the new key, so the alias list doubled with every line. On top of that, the contains check before each push was linear. A few lines of this is harmless, but the cost grows as 2^n, and one ~1,100-line file that does it a lot (with locals copied between functions) was enough to keep Flow::of busy for minutes, and the startup scan never finished.

  • tables_of now drops tables read through MAX_KEYS keys or more. Every consumer requires keys.len() > prefix.len(), and tracked fields have at most MAX_KEYS keys, so those entries could never match anything. The results stay the same.
  • Aliases now holds a FxHashSet instead of a Vec. The consumers only use any, so order doesn't matter. Locals copied from each other still pick up thousands of entries, and this removes the quadratic dedup.

2. Every save re-checked the whole workspace (server.rs)

After a save, VS Code also sends workspace/didChangeWatchedFiles (CHANGED) for the file it just wrote. watched_files_changed set workspace_stale = true for any change, so each save re-linted every closed file in the workspace.

  • CHANGED events for documents that are open are ignored, because didSave already marks their resource as stale.
  • Other on-disk changes now mark only the resources of the changed files, the readers that refresh_readers reached, and the resources of files that now declare something differently. That is the same scope a save uses.
  • The whole workspace is still checked again when a .cfg file, a manifest, or a lint config changes, or when imports are relinked.
  • A deleted file now has its diagnostics cleared directly, since nothing checks it again. Before, the full pass took care of that.

I tracked this down and wrote the fix with help from generative AI (Claude Code, Opus 5.5). That included profiling, bisecting the slow file, and drafting the patch. I reviewed the changes and checked the results myself.

Benchmarks

Release build on Windows, same private workspace for both: ~800 Lua files, 146 resources.

1.1.0 this PR
qbx-lua-ls --index on the whole workspace killed after 300 s 5.5 s
Startup scan over LSP (indexed … in) not done after 120 s 3.0 s
Flow::of, first 740 lines of the heaviest file 10.3 s 7.7 ms
Flow::of, the same file in full (~1,100 lines) did not finish 78 ms
codeActionsOnSave reply (fixAll + organizeImports) after an edit queued behind the workspace pass < 1 ms

The Flow::of cost grew exponentially with the number of t = t.key lines. On the 740-line prefix, line 739 took 343 ms and line 740 took about 20 s. For the full file, the first change alone (dropping the long tables) brought it to ~940 ms, and the hash set brought it to 78 ms.

For the save numbers I used a small LSP client that opens a large file, sends a few edits, then sends the two codeAction requests VS Code makes on save, followed by didSave and the watched-file event, three times in a row. With 1.1.0 the client never got past the startup scan, so I have no per-save number for the old build.

Checks

  • cargo test --workspace --locked --release: 593 passed, 0 failed
  • cargo fmt --all --check and cargo clippy --workspace --all-targets --locked -- -D warnings: clean
  • New regression tests, and I confirmed both fail without the fix:
    • workspace_refresh::locals_assigned_from_their_own_fields_over_and_over_are_indexed_quickly: 40 chained t = t.keyN assignments. It hangs on main and takes a few ms with this change.
    • lsp::saving_an_open_document_leaves_the_other_resources_unchecked: a save plus the watched-file event no longer re-publishes another resource's closed file, and a real on-disk change to that file still re-checks it.
  • Built the qbx-editor VSIX with this server (npm run server + vsce package --target win32-x64) and ran the LSP client above against it.

@ChatDisabled
ChatDisabled merged commit 6d9bba9 into Qbox-project:main Nov 1, 2026
2 checks passed
@HeyyCzer
HeyyCzer deleted the fix/slow-saves branch November 1, 2026 22:18
@HeyyCzer
HeyyCzer restored the fix/slow-saves branch November 1, 2026 22:18
@HeyyCzer
HeyyCzer deleted the fix/slow-saves branch November 1, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants