What the session holds
The session is the file that makes a relaunch feel like nothing happened. It lives at sessions/session.json inside ~/Library/Application Support/cwkVoyager/, and its shape is three nested values:
SessionDocument: a schema version and the windows.SessionWindow: the tabs, the index of the one in front, and the window's frame as the platform wrote it. The parent keeps the frame as a string and never reads it.SessionTab: id, identity, address, title, the page's place as bytes (lesson 2), pinned, icon address, and whether the tab is sharing with Pippa.
Private windows and private tabs are never written. Everything else is, including each tab's interactionState bytes, which is why the file grows with Dad's habits. One page's place measured 624 bytes in the engine gate; a tab with a long back list carries more. By the usability review of 2026-10-22, Dad's session file had reached 1.2 MB.
Apart from the ids that make a tab a tab (its id and identity) and the lists themselves, each field decodes with a default: a missing pinned reads as false, a missing title as empty, a missing window selection as 0. Swift's synthesized Decodable throws on a missing key even when the property has a default, so adding one field would otherwise make every older session unreadable. A test decodes a session written before those fields existed. The helper behind it, decode(_:forKey:or:), comes from the family's shared Swift kit, which Sibling Kit Quest covers.
An unreadable file is kept, never overwritten
The most dangerous line in any app that persists state is "load it, or use the default, then save". When the load fails, that line replaces the only copy of Dad's tabs with an empty document, and it does so without a word.
Voyager's session store refuses that path twice. First, load moves a file that will not read aside, to session.unreadable-<UTC stamp>.json beside it, starts the run with an empty session and says so in the window. If even the move fails, it starts empty, leaves the file where it is, and the app stops saving the session for this run. Second, the shared document store underneath refuses to overwrite a file it cannot decode, so a bug in the first guard still cannot destroy the file. The architecture's failure table names the recovery: restore from the kept file by hand.
One honest wrinkle: the app tells the two cases apart by reading the notice's wording ("will not be saved"). It works, and a test covers the first case, but a typed result would survive a change of wording better.
When it is written
Every change a session cares about (a tab opened, moved, pinned, renamed by its page, a new icon) calls sessionChanged(). The app delegate coalesces those calls: the first one starts a two-second timer, the rest ride on it, and when it fires one snapshot of every non-private window is handed to SessionWriter.
Until the usability review, that write happened on the main thread. A page whose title changes every few seconds (an unread count is enough) made the main thread build, check and write a megabyte of tab places each time. SessionWriter moved it to a serial queue and added one more rule: a document equal to the last one written is not written at all. If the store refuses a write, the reason comes back to the main actor, the window says the session could not be saved, and saving stops for the run rather than failing again every two seconds.
Quitting is the one time the app waits. applicationShouldTerminate asks the writer for writeNow, which runs on the same serial queue, so it lands after any write still pending and returns only when the file is on disk. History flushes its last second of visits at the same moment. If Dad quits with no window open, the last window he closed is what gets saved, so it comes back.
SIGTERM is a quit too
The install script replaces a running copy of Voyager by sending it SIGTERM, then waits up to ten seconds (40 checks, a quarter second apart) before giving up loudly. If SIGTERM simply killed the process, every install would lose the session. So the app ignores the default handler, listens for the signal with a dispatch source on the main queue, marks the quit as a signal quit, and calls NSApp.terminate, the same path the menu takes. The session is written. The one difference is that a signal quit never asks "a download is still running, quit anyway?", because a question nobody is there to answer turns into a failed install.
Coming back
restore rebuilds each window from its SessionWindow. Private tabs are skipped if any slipped in. A tab whose identity has since been removed opens in the default identity and drops its saved place, since the bytes belonged to a store that no longer exists. Pinned tabs go first by partition (lesson 1). Then exactly one tab is chosen and woken. Every other tab stays asleep with its title and icon, so a window restored with forty tabs starts at most one web view for them, and the rest wake one at a time, where Dad left them, when he clicks.