All features

Why it froze

Try the real interface

Explore this workspace.

Real Torbie UI · mock sessions
Tab bar
Loading Torbie…
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: 1

    Environment variable. 0 disables the lot.

  • TABBY_DIAG_STALL_MS: 250

    How long a block has to last to be a stall.

  • TABBY_DIAG_INSTRUMENT_IO: 1

    0 keeps 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.
  • syncMs versus ms decides 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 fs wrapper must go on the module require returns. import * as fs compiles 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-window unresponsive with how long it lasted, renderer unhandledrejection, main uncaughtException, 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/unlinkSync on 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.
Source commits
DiagnosticsAdded 2026-08-2012 files+807 / −15 lines