Why it froze
Explore this workspace.
Technical background
A frozen window otherwise leaves no trace: Windows calls the process responding, nothing throws, and the app log had no timestamps to line anything up against. Every blocked event loop in the main process and in every renderer is recorded, with the synchronous calls that blocked it tallied by name — because what freezes this app is tens of thousands of individually-fast calls, not one slow one.
The problem
A frozen window leaves no trace. Windows calls the process responding, nothing throws, no crash dump is written, and until now the app log had no timestamps to line anything up against. "It hung again" was unanswerable after the fact.
How it works
app/lib/diagnostics.ts records what blocks an event loop, in the main process and in every renderer, to <config dir>/diagnostics.log as JSONL. Every synchronous fs and child_process method is wrapped and counted; stacks are sampled every 500 calls and deduplicated, so a burst is attributed without paying for a capture on each one. The summary line is usually the whole answer.
Settings
TABBY_DIAG:1Environment variable.
0disables the lot.TABBY_DIAG_STALL_MS:250How long a block has to last to be a stall.
TABBY_DIAG_INSTRUMENT_IO:10keeps the stall detector without the per-call I/O tally.
A real stall record, summarised
renderer event loop blocked 71.3s during "ready" — 98% synchronous I/O: fs.readFileSync ×58214 (41.0s), fs.unlinkSync ×58214 (28.2s)
Details
- The tally is the point, not a slow-call threshold. What freezes this app is tens of thousands of individually-fast synchronous calls — draining a spool directory, walking a build tree — where no single call would trip a "slow call" limit but the sum blocks the UI for minutes.
syncMsversusmsdecides where to look. A stall that is mostly synchronous I/O names its own fix; one with almost none is script or GC, and no amount of I/O detail would have helped.- It installs before zone.js and the plugin loader. The detector runs on timers captured before zone.js patches them — a zone-patched interval would schedule a change-detection pass every tick, and would stop reporting at exactly the moment the zone is what is wedged.
- The
fswrapper must go on the modulerequirereturns.import * as fscompiles to a copy whose properties are forwarding getters; assigning a wrapper onto that throws straight into our own catch and instruments nobody, with reports still arriving and attribution always empty. - Records are size-capped by dropping whole fields, never by cutting the string. A JSONL log whose long lines do not parse is worse than one that admits it left something out. Lines stay around 1 KB, inside the size where an append from several processes still lands atomically.
- Writes are buffered and asynchronous: an instrumentation that blocks the loop to report that the loop was blocked would be measuring itself. Overhead is two
performance.now()calls and a map lookup per synchronous call, about 200ns. - Also recorded:
render-process-gone,child-process-gone, per-windowunresponsivewith how long it lasted, rendererunhandledrejection, mainuncaughtException, and boot phase marks so a stall says what was in progress when it hit.
What this does not claim
- Two known offenders it has already named are still unfixed at the source. A plugin's spool drain does uncapped synchronous
readdirSync/readFileSync/unlinkSyncon the renderer thread, and its hook writes one file per event and never prunes — so the backlog is proportional to how long Tabby was not running: measured 0.126 ms/file warm, and a 3.5-day gap is about 60,000 files. And a cold main process blocked 17.4s during startup on module loading alone. - It runs on the event loop it is measuring, so a wedged main process is beyond it by construction.