DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A laptop lid is not a restart: seven ways our app stops, and what each one is allowed to forget

Notifio is an Electron app that polls rental search pages on your own machine and tells you the moment a listing appears that was not there before. "Was not there before" is doing a lot of work in that sentence, and all of it depends on the app's own lifetime.

A desktop app has more ways to stop than a server does. Closing the window, minimising it, the tray, the lid of the laptop, a second launch, a crash, and an actual quit are seven different things, and they are seven different things even though the user experiences most of them as "I put it away". Each one has to be decided separately, and the question for each is the same: what is this event allowed to destroy?

The rule we settled on

The one piece of state that matters is the per search baseline: a snapshot of what the search page held last time we looked. A listing is new if it is not in that snapshot. (I wrote about the diffing side of that in Most of our diff code exists to not send an alert.)

The rule is keyed to the process, not to the clock:

/**
 * The rule is about the app's lifetime, not about elapsed time:
 *
 *   - The first check of a search after the app starts re-bases.
 *   - Every check after that in the same run is a real comparison.
 */
export class BaselineTracker {
  private checked = new Set<string>();

  claimFirstCheck(url: string): boolean {
    if (this.checked.has(url)) return false;
    this.checked.add(url);
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

An earlier version used an age threshold instead, re-basing only when the stored snapshot was more than thirty minutes old, on the reasoning that a quick quit and reopen should still be able to diff and miss nothing. That is wrong for two reasons, and the second one is worse than the first.

The first is a product reason. A snapshot written by a previous run describes what the page held when the app was last open. Rooms posted while the app was shut have already been seen by everyone who was watching, so alerting on them is an email about rooms that are gone. The whole proposition, which the how to be first guide spells out, is that you hear about a listing before the crowd does. An alert that arrives after the crowd is worse than no alert, because it is indistinguishable from a useful one until you have clicked it.

The second is mechanical. Diffing against a stale snapshot makes most of the page look new, which trips the app's own "too much changed, this is probably not a page of new listings" guard, and the cycle reports nothing at all. So the time based version did not even deliver the thing it was trying to deliver.

What each event does

Event Process Baseline Live state Browser contexts
Minimise alive kept kept kept
Close window (to tray) alive kept kept kept
Machine sleeps alive kept kept closed
Stop the monitor in the app alive kept cleared kept
Quit gone gone gone closed
Second launch the first one kept kept kept
Crash gone gone gone orphaned until relaunch

Three rows in that table are the interesting ones.

Closing the window is not quitting

mainWindow.on('close', (event) => {
  if (!(app as any).isQuitting) {
    event.preventDefault();
    mainWindow?.hide();
  }
});

app.on('window-all-closed', () => {
  // Keep app running in tray
});
Enter fullscreen mode Exit fullscreen mode

That empty handler is deliberate and it is the opposite of the Electron default on Windows and Linux, where all windows closed means quit. For an app whose entire job happens on a timer, quitting when the window closes would mean the product only works while you are looking at it.

The cost is that the window gets re-shown a lot, and when it is re-shown we reload it:

mainWindow.on('show', () => {
  mainWindow?.webContents.reload();
});
Enter fullscreen mode Exit fullscreen mode

Which is a blunt fix for stale rendering, and it is also the reason the renderer is not allowed to own anything. That turned into its own post: The window reloads every time you open it, so it cannot own the state.

Sleep closes the browser, and resume does nothing

powerMonitor.on('suspend', () => {
  logFile('[main] System suspending, closing browser contexts');
  try {
    const scraper = require('./scraper');
    scraper.closeAll().catch(() => {});
  } catch { /* scraper not loaded yet */ }
});

powerMonitor.on('resume', () => {
  logFile('[main] System resumed');
});
Enter fullscreen mode Exit fullscreen mode

A Chromium context that was open when the machine suspended is not reliably usable when it comes back, and the failure arrives as an error from whatever call touches it next, in the middle of a poll, which is the least useful place to find out. So we throw the contexts away on the way down.

resume deliberately only logs. There is nothing to rebuild, because contexts are opened lazily by the next check that needs one, and the next check is at most one interval away. An eager re-open would be work done on the user's behalf at the exact moment their machine is busiest, to save a few hundred milliseconds that nobody is waiting on.

Note which column sleep does not touch: the baseline survives it. The process is alive the whole time, the tracker is a Set at module scope, and a lid is not a restart. A sleeping laptop looks exactly like a slow poll interval to this part of the code, which is the correct way for it to look.

Stopping the monitor clears live state but not the baseline

The app has a stop button, and the per search runtime state (checking, ok, blocked, when the last check finished) is cleared when you press it:

export function stop(): void {
  ...
  siteState.reset();
  emitStatus('stopped');
}
Enter fullscreen mode Exit fullscreen mode

Because that state describes a run that is no longer happening. A row saying "checking..." under a stopped monitor is a lie, and a lastCheckedAt of four hours ago presented as current state is a subtler one.

But the baseline tracker lives at module scope and stop() must never touch it. Stopping the monitor is not restarting the app. If pressing stop and then start re-based every search, the user would have asked for a pause and received a hole in their coverage, which is exactly the failure they are paying us to not have.

The accepted trade at the far end of the table is the crash row: a crash and relaunch re-bases, so anything posted while the app was down goes unalerted. That is the same trade as a deliberate quit, and we would rather miss a listing than send an email about a room that was taken an hour ago.

One copy, enforced

const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
  app.quit();
} else {
  app.on('second-instance', () => {
    if (mainWindow) {
      if (!mainWindow.isVisible()) mainWindow.show();
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.focus();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Every desktop app wants this so the dock icon does not launch a second copy. This one needs it for a second reason: two running copies would be two monitors polling the same search pages on the same schedule, which doubles the request rate we present to a site we do not own, and gives each copy its own baseline so both would alert on the same listing. A user who double clicked the icon would have quietly made the product worse at the thing it exists to do. The lock turns that into focusing the window they already had.

The shape of it

If you are writing something that watches, the useful exercise is to list every way your process can end or pause, and then say out loud what each one invalidates. Most of the bugs in this area come from treating two of those events as the same event because the user's description of them is the same.

The app is at notifio.app/download, the tray and restart behaviour is documented in notifio.app/help, and notifio.app/alerts has a page per supported site.

Top comments (0)