A startup error must not be a modal nobody sees
Explore this workspace.
Technical background
dialog.showErrorBox is modal and synchronous — measured, a process showing one ran not a single timer callback in fourteen seconds. So a startup error left a process alive with no window, holding the single-instance lock, unable to run the watchdog that exists for exactly that. The failure is recorded first, the lock released second, and the box shown on its own thread third.
The problem
dialog.showErrorBox is modal and synchronous: the main loop stops inside it until someone clicks OK. Measured — a process showing one ran not a single timer callback in fourteen seconds. So a startup error left a process alive, with no window, holding the single-instance lock, unable to run the watchdog that exists for exactly that; and on an unattended launch nobody ever saw the box. This is the third hostage shape, and it is what made the other two unreachable.
How it works
Four rules, in this order. Record before anything else — the failure to both logs, then a synchronous flush, because app.exit() runs no timers. Release the single-instance lock before saying a word, so the hostage property is closed by one call rather than by everything after it going right. Never block the loop — dialog.showMessageBox runs its dialog on its own thread and answers with a promise. Don't decide the exit here: whether there is anything worth keeping is the watchdog's question, and it already answers it carefully.
Settings
TABBY_FATAL_DIALOG:1Environment variable.
0skips the dialog outright.TABBY_FATAL_DIALOG_MS:120000How long the dialog may stay up before the process gives up on an answer.
Measured before and after, on a window construction that throws
the failed launch before: still there at 19s, nothing logged
after: exits itself in 5.3s, exit 1
the launch after it before: handed to it, exit 0 after 1.3s, no window
after: its own process, app:ready in 1.7s
unparseable config before: modal, forever, invisible
after: exits in 1.2s, record flushedDetails
--hiddenis the only certain "nobody is watching". Nothing on Windows separates a double-click from a startup item, so the box is shown by default and skipped only where the answer is known: a launch that asked for no window at all. Nobody loses the error that way — a hidden Tabby that failed to start is one the user launches again the ordinary way, and that launch is not hidden, with both logs already written.- Before
app.readythe blocking box is still the only one available, so a config that will not parse getsshowErrorBox— tolerable there and nowhere else, because the single-instance lock has not been requested yet, so that process is holding nothing. - Handing the exit back to the watchdog also means a failure at the tail of startup no longer kills a window that had already reached
app:ready. - The attended checks are opt-in, because they put a real dialog on the screen. They read the box's own title from outside and then let it hit the cap: a timer firing while the box is up is the proof that it is no longer blocking.
What this does not claim
- The documented lever no longer throws. A geometry file with non-numeric bounds is now cascaded past rather than fatal, so the test reaches the same code path by other means — a directory where the file should be, which reads as EISDIR: neither ENOENT nor a syntax error, so it rethrows from inside the same constructor.
report()'s detail must not carry akindfield — it is spread over the record after its own kind, so the record silently files under the other name. Cost a round trip.