A desktop agent on an operational machine is not a consumer app. Nobody is looking at the screen, the machine is doing work that has a physical side effect, and the person nearest it is holding a box, not a mouse. Auto-update logic written for a chat client will, on that machine, restart the process while a label is halfway out of the printer and leave the agent disconnected until someone notices the shelf of unprinted orders.
We ship such an agent. Station 1.0.5 added in-app updates, and 1.0.6 fixed a reconnect bug the update path exposed. These are the rules we now hold the update path to, with the bug included, because the rule about reconnecting was learned rather than designed.
An operational desktop has three properties a laptop does not
First, work in progress is physical. A print job handed to the OS spooler will finish regardless, but a job the agent has received and not yet handed off is lost or duplicated if the process dies at the wrong moment.
Second, there is no operator in the software sense. Dialogs go unread. A modal that blocks the agent until someone clicks it is an outage.
Third, identity matters. The agent holds credentials that bind it to a customer and a place, and the server routes work by that identity. An update that loses the credential, or changes what the agent reports about itself, silently breaks routing.
Rule 1: check quietly and download in the background
The check should be invisible and infrequent. Station checks for updates 30 seconds after startup and every 24 hours, and the operator can trigger a check from the menu bar or tray. When a newer stable release exists it downloads in the background while printing continues. Do not check on every reconnect; a fleet of machines that wake and sleep all day would produce a synchronized burst at your feed.
Rule 2: never install mid-job, and let the operator postpone
Once the download completes, the agent offers two choices: restart now, or later. "Later" must be a real option that leaves everything working. Station's controls are Restart and update and Later, and a postponed build keeps running the current version.
When a restart is requested, the agent still does not exit immediately. It closes its admission gate so no new job is accepted, waits for cloud print jobs and local test prints already in flight to finish submitting to the OS spooler, and only then disconnects and hands off to the native installer. Postpone update cancels that wait and reopens the gate.
This is the rule that costs something. The gate covers the agent's own work: jobs it has accepted and not yet handed to the spooler. It does not cover paper. A job the spooler accepted will print during the restart, and the agent cannot know whether the paper emerged. Jobs still queued on the server stay there and are delivered after relaunch, the same as any other disconnect (what happens to labels when the printer goes offline).
Rule 3: preserve identity and settings so the relaunch is boring
The new version must find its credentials, its printer settings, and its record of finished jobs exactly where the old one left them. Station keeps all three in the existing application data directory, and the installer does not touch it. After relaunch the agent reconnects with the same device-bound token and re-registers the same station id, so the server's routing does not change.
The record of finished jobs matters most during an update. If the agent printed job A and restarted before its completed message reached the server, the server redelivers A on reconnect, and the agent replays the saved outcome from its ledger instead of printing again. That is the second half of the design in how idempotency prevents duplicate labels.
Rule 4: reconnect deterministically, and test the teardown
This is the one we got wrong. Station's connection to the API is an outbound WebSocket that reconnects forever with capped backoff (1 s, 2 s, 4 s, up to 30 s). On every successful open it sends hello, then re-reports its printers and scales so the server can rebind routing.
The problem was in how connect() replaced an existing socket. The 1.0.5 code removed the old socket's listeners, called close(), and moved on. Two things go wrong with that sequence.
First, the ws library emits error on the next tick when you close a socket that is still CONNECTING. With the listeners already removed, that error has no handler, and an unhandled error event on a Node EventEmitter throws. In an Electron main process that is an uncaught exception.
Second, the old socket may already have scheduled a reconnect timer from an earlier failure, and connect() did not clear it. When that timer fired it called open() again and replaced the new, healthy handshake with yet another socket. The visible symptom was an agent that appeared to reconnect after an update or a network change and then sat with no live socket.
The 1.0.6 fix moves teardown into one function:
private closeSocket(): void {
if (this.outcomeTimer) clearInterval(this.outcomeTimer);
this.outcomeTimer = null;
const ws = this.ws;
this.ws = null;
if (ws) {
ws.removeAllListeners();
// Closing a CONNECTING socket emits 'error' on the next tick, outside
// try/catch. Consume teardown errors without letting retired sockets
// publish status, handle jobs, or schedule retries for a new connection.
ws.on('error', () => {});
try { ws.close(); } catch { /* already discarded */ }
}
this.proxyAgent?.destroy();
this.proxyAgent = null;
}
And open() now clears any pending reconnect timer before calling it, so an explicit connect supersedes a retry scheduled by the socket it replaces. The retired socket keeps a no-op error handler, so it can fail quietly; it cannot publish status, handle jobs, or schedule retries for the session that replaced it.
The tests are the part we should have written first. They run a real WebSocket server on a loopback port and control when each upgrade is accepted, so they can hold a handshake open and call connect() again mid-flight, reject the first attempt and verify the obsolete retry never fires after the new handshake, and confirm that after an unexpected disconnect the agent re-sends hello, printers, and scales in that order. Reconnect logic is timing logic, and timing logic without a test that controls time is a guess.
The general rule: the transition from old socket to new socket must be one function with one owner, it must clear every timer the old socket could fire, and it must be tested with the old socket in every state, including "not yet open."
Rule 5: keep rollback possible, which for us means forward-fix
Update artifacts are immutable. A release ZIP or installer, once published, is never overwritten, and its manifest carries the byte length and a SHA-512 checksum computed from the stored bytes on the server. If the download fails, the checksum does not match, or the native install fails, the current version stays installed and usable and the agent offers a retry. Ordinary application exit never triggers an install.
A bad release is handled by withdrawing its build from the feed and publishing a higher version with the repair. Downgrades are disabled on the client, deliberately: an agent that can be told to install an older version can be told to install a vulnerable one.
What that choice costs us: withdrawing a feed cannot recall bytes a Station has already downloaded and is waiting to install, and we have not implemented staged rollout percentages. A bad build reaches every station that checks in before we withdraw it.
Two more limits worth stating. Stations running 1.0.4 or earlier need one manual installer upgrade before any of this applies to them, because the in-app update path did not exist yet. And this whole path covers two platforms: Station ships for macOS (Apple silicon and Intel) and Windows x64, and Linux is not available for self-service installation.
Rule 6: sign everything, and verify on the client
The update feed is served over HTTPS and identified by four fields (edition, application id, authentication provider, platform and architecture), all bound on the server, so a branded edition cannot be fed another edition's build. Only a signed, ready build with an update artifact enters a feed. On Windows the client pins the packaged signing publisher; on macOS the native updater verifies the replacement app's signature before installing.
One limit to state: the manifest is delivered over authenticated TLS but is not a separately signed document. The artifact is what is verified.
The checklist
- Check on a slow cadence with jitter across the fleet; never on every reconnect.
- Offer a real "Later" and a real "Postpone" that reopens work.
- Close the admission gate, finish in-flight work, then exit; know what "in-flight" does not cover.
- Keep credentials, settings, and the finished-work ledger in a directory the installer never touches.
- Reconnect through one teardown function that clears every timer, tolerates a socket in any state, and is tested against a real server on loopback.
- Publish immutable, checksummed, signed artifacts; leave the current version usable when anything fails.
- Fix forward with a higher version; disable downgrades; know that withdrawn feeds do not recall downloaded bytes.
- Write down what you have not built (for us: staged rollouts, downloaded-update revocation).
RocketPrint is a remote printing API and desktop Station for software platforms: your backend POSTs a print job, and a small app beside the printer prints it, with no inbound networking, no drivers for raw label formats, and a durable job record. Station's update behavior and troubleshooting are on the Station docs page, and 1.0.6 is in the changelog. The useful habit is asking, at every step of the update, "what is the machine doing right now, and who is standing next to it."
END / desktop-agent-auto-updates-that-do-not-break-the-shift
More field notes →