Notifio is a desktop app that watches rental search pages and tells you when a new listing appears. It is an Electron app, and the shape of it is slightly unusual: the main process starts an Express server in-process, and the window loads http://localhost:3000 rather than a file. I have written about the consequences of that before, in Our Electron renderer has no preload script, and it talks to 28 HTTP endpoints on 127.0.0.1.
This post is about one branch of that startup, the one that runs when it does not work.
In development, a failed start is free
pnpm dev compiles, builds the renderer and runs electron . from a terminal. If anything in startup throws, the stack lands in the terminal I am already looking at. There is nothing to design.
Packaged, that same failure is an app that appears in the dock for half a second and disappears. No window, no message, no stack, and console.log writing to a stdout that nobody attached a terminal to. The user's report is "it does not open", and that is all the information that exists.
So the startup path has an explicit else:
app.whenReady().then(async () => {
logFile('[main] app ready');
try {
await startServer();
logFile(`[main] Server ready on port ${serverPort}`);
createWindow();
createTray();
logFile('[main] Window and tray created');
} catch (err) {
const msg = err instanceof Error ? err.stack || err.message : String(err);
logFile(`[main] STARTUP ERROR, showing fallback window. Error: ${msg}`);
mainWindow = new BrowserWindow({ width: 800, height: 400, show: true });
mainWindow.loadURL(
`data:text/html,<pre style="font:14px monospace;padding:24px;background:#0a0a0a;color:#fafafa">Notifio failed to start.<br><br>${msg.replace(/</g, '<')}<br><br>Check logs: ${LOG_PATH}</pre>`
);
}
});
Three decisions are packed into that catch, and the interesting one is the URL scheme.
Why the error window is a data URL
The obvious implementation is an error.html in the app bundle, loaded with loadFile. It would be nicer to style, it would not need escaping, and it would be a normal web page.
It would also be a file, and the class of bug this branch exists to report is packaging. The failures that reach this catch in a packaged build are things like a path that resolved differently inside an asar, a require of a module that the file globs excluded, or a native dependency that did not survive the copy. In other words, the state of the world when this code runs is "something that was supposed to be on disk is not where the app thinks it is". Handing that situation a path to another file on disk is asking the broken thing to report on itself.
A data: URL is the only renderer input that cannot be missing, cannot be at the wrong path and does not need the Express server that just failed to come up. It is a string in the already-loaded main process bundle. If the main process is running at all, the error window renders.
The same reasoning rules out the two other tempting options. dialog.showErrorBox is available and would be one line, but it gives a native alert with no selectable text on some platforms, and the thing the user needs to do with a stack trace is copy it. Writing to stdout is what already happens, and it reaches nobody.
The one-character escape
${msg.replace(/</g, '<')}
The message is being interpolated into HTML, and stack traces are full of angle brackets: <anonymous>, Object.<anonymous>, TypeScript generics in the frames. Without the replace, the first <anonymous> in the trace becomes an unknown element and the rest of the line vanishes into it, which turns the one artefact the user was asked to copy into a shorter, wrong artefact.
It is deliberately not a general HTML escape. There is exactly one source for this string, our own Error, and < is the only character in it that changes the parse inside a <pre>. An & renders as an entity if it happens to start one, which is cosmetic in a window whose job is to be legible enough to screenshot. A general escape here would be more correct and would also be more code in the path that runs when code paths are not working.
Worth saying plainly: this is a different standard from the one that applies to user input. The reason it is safe is not that escaping does not matter, it is that nothing a stranger controls can reach this string.
The log path is in the window on purpose
const LOG_PATH = path.join(app.getPath('userData'), 'logs.txt');
function logFile(msg: string): void {
const line = `[${new Date().toISOString()}] ${msg}\n`;
process.stdout.write(line);
try { fs.appendFileSync(LOG_PATH, line); } catch { /* ignore */ }
}
logFile writes to two places, and the file is the one that matters, because userData is outside the app bundle and survives a reinstall. Startup logs a handful of facts deliberately chosen to be the ones I cannot guess from a bug report:
logFile(`[main] __dirname = ${__dirname}`);
logFile(`[main] app.isPackaged = ${app.isPackaged}`);
logFile(`[main] PLAYWRIGHT_BROWSERS_PATH = ${process.env.PLAYWRIGHT_BROWSERS_PATH}`);
Then the fallback window prints the absolute path to that file, so the support conversation starts with "send me this file" rather than with me explaining where application support directories live on two operating systems. The conversation itself starts at notifio.app/help, which has the contact form, and every email that arrives from it is a stack trace plus a path I already asked for.
The try/catch swallowing the append is the small print: a log write that fails must not be the reason the app cannot report why it failed.
The process-level handlers do not exit
process.on('uncaughtException', (err: Error) => {
logFile(`UNCAUGHT: ${err.stack || err.message}`);
});
process.on('unhandledRejection', (reason: unknown) => {
logFile(`UNHANDLED REJECTION: ${String(reason)}`);
});
Node's default for an uncaught exception is to exit. For a server that is the right default, because a process in an unknown state should be replaced by a supervisor. Notifio has no supervisor. It is an app on someone's laptop whose whole purpose is to still be running in four hours, and the state it keeps while running is the reason: each search holds an in-memory snapshot of what its page looked like on the previous check, and the first check after a restart has to rebuild that snapshot silently rather than alert on everything it sees. I wrote about why in The first check after a restart is not allowed to tell you anything.
So a crash is not a clean slate here, it is a gap. Logging and carrying on is the better of two bad options, and the honest description of these handlers is that they trade correctness in an unknown state for the chance that the unknown state was in a code path that does not matter.
Where the branch is weaker than it looks
Two things about startServer are worth stating rather than implying.
require('./server');
// ...
setTimeout(() => {
logFile('[main] Server started successfully');
resolve();
}, 2000);
The require is synchronous, so a module that throws while loading, the packaging failure class, lands in the catch and gets the fallback window. That is the case this was built for and it works.
The two second timer is not a readiness check. It is a guess that Express has bound its port by then, and a server that is slow or that fails after the require resolves gets a normal window pointed at a port nothing is listening on, which renders as a browser error page inside the app frame. That is a worse experience than the fallback window, and it is the next thing to fix: the correct version waits on the listening event and rejects on error, which turns both of those into the same catch.
I am writing it down rather than quietly fixing it first because the gap is the interesting part. The fallback window makes one failure mode legible and gives the other one a confusing shape. A failure path that is only wired up to some of the failures is the normal state of this kind of code, and knowing which ones is the difference between a support email with a stack trace in it and a support email that says "it does not open".
If you want to see the thing all of this is protecting, the installers are at notifio.app/download, and what the app actually does with a search page once it has started is on the per-site pages such as notifio.app/alerts/kamernet.
Top comments (0)