Godot Code Studio: Bug hunting
bugsgodot code studio
All 304 tests passed before I touched a single line. Every bug below lived in code the tests never reached. That’s the uncomfortable truth about testing — coverage isn’t a moat, it’s a fence, and bugs don’t respect fences. They were all having a party in the yard.
Here are the ones worth telling you about.
The phantom “this file changed on disk” dialog
The nastiest one. GCS watches your open files for external changes — if a git checkout or another editor modifies a file, you get a polite “reload and lose your in-editor changes?” prompt. Except the check compared the file on disk against a baseline captured when the file was opened. And saving never re-captured it.
So: you open a file, edit it, hit Ctrl+S, click back into the editor… and GCS prompted you to reload and discard “external” changes. The external change was your own save. Accept the prompt and any LSP edits that landed in the meantime (applied formats, renames) get silently wiped with it. A data-loss footgun wearing a politeness costume.
Fixed two ways: the save path now re-baselines after the write is verified, and a belt-and-braces repair at the focus check silently heals a stale baseline whenever the buffer happens to equal the disk text. No more dialog for your own keystrokes.
Emoji could corrupt your code (yes, really)
This one is my favorite, because it’s a whole category hiding in one bug. The LSP protocol measures text positions in UTF-16 code units — that’s what the spec says, it’s what VS Code does, it’s what every server on the planet assumes. Godot strings, however, are counted in code points. For ASCII and most text those are the same thing. But put one emoji or a rare CJK character in a line and the two scales drift apart — an emoji is 1 code point but 2 UTF-16 units.
GCS advertised UTF-16 in its client capabilities and then counted code points. Every position on a line after such a character was off by one. A “go to definition” landing beside your cursor is annoying; format document applying an edit at a shifted offset can splice text into the middle of a token and corrupt the buffer.
The fix is proper conversion at every LSP boundary — incoming positions get decoded from UTF-16, outgoing positions get encoded, and a position reported inside a surrogate pair snaps to the pair’s start instead of splitting it. There’s now a test file whose fixtures contain emoji, because if a language server can’t survive, it can’t survive the internet.
File names with spaces, percent signs, and hashes
URI handling was asymmetric: encoding escaped spaces (%20), decoding was literally
“delete the file:// prefix.” No percent-decoding at all. A file named 100%.gd
or a#b.gd round-tripped into a path that pointed at nothing, which meant
diagnostics, renames, and workspace edits for those files went nowhere. Windows
drive URIs (file:///C:/...) were broken by construction.
There’s now a real, centralized pair of helpers — proper RFC 3986 encoding and decoding, authority stripping, the Windows drive-letter special case — and every call site goes through them. Twelve round-trip tests cover the ugly names.
The quiet one: failed saves that lied
Saving used to flip the buffer’s “clean” flag when the save started, not when the bytes landed. If the write failed — permissions, disk full — the editor happily believed your work was safe and lost it on exit. Nobody would ever file this as a bug report; you’d just quietly lose a session and blame yourself.
Saving is now a single synchronous, verified path: write, verify what’s on disk actually matches, then clear the dirty flag and tell the language server. A failed write keeps the buffer dirty and tells you instead of lying.
Bonus debugging story from this one, because it’s too good not to share: my first implementation verified the write by checking the file size while the write handle was still open — and read a stale size on every save, silently failing everything. I only caught it by writing a debug log to /tmp and watching it report failure on saves that visibly worked. The verification now runs after the handle closes, and the test suite is 25 tests heavier because of it.
The rest of the report
The smaller finds, fixed the same week: LSP completion icons cast raw protocol
numbers into the wrong Godot enum (functions rendered as variables), the
workspace/applyEdit reply claiming success for payloads it didn’t understand, a
missing binary-file guard that could have mangled a texture through find & replace,
a wrong range in go-to-definition links, and a handful of log-format mismatches.
The full report — including a “reviewed and cleared” section so the next hunt
doesn’t re-litigate what’s fine — lives in the repo’s .plans/ folder.
Where things stand
- 304 → 329 tests, all green on every platform CI builds
- No behavioral regressions: every pre-existing test still passes untouched
Bug hunting should be done way more regularly than one thinks - even the broadest test suite sometimes does not cover every edge case.
— 5cump1