Skip to content
C.W.K.
Lesson 03 of 05 · published

Sessions That Survive

~14 min · persistence, codable, concurrency, recovery

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

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.

Principle: A file you cannot read is evidence, not garbage. Move it aside with a stamp, start clean, say so, and make the writer refuse to overwrite what it does not understand.

Code

cwkVoyager, Sources/VoyagerCore/Session.swift: an unreadable session is moved aside, never overwritten (verbatim, lines 98-117)·swift
    /// The stored session. A file that will not read is moved beside itself
    /// under a stamped name and an empty session is returned with a notice.
    public func load(now: Date = Date()) -> Loaded {
        do {
            return Loaded(document: try store.load(materializeDefaults: false).document, notice: nil)
        } catch {
            let kept = url.deletingLastPathComponent()
                .appendingPathComponent("session.unreadable-\(Self.stamp(now)).json")
            do {
                try FileManager.default.moveItem(at: url, to: kept)
                return Loaded(
                    document: SessionDocument(),
                    notice: "The saved session could not be read and was kept as \(kept.lastPathComponent). Voyager started with an empty one.")
            } catch {
                return Loaded(
                    document: SessionDocument(),
                    notice: "The saved session could not be read. It is left untouched, so this session will not be saved.")
            }
        }
    }
cwkVoyager, Sources/VoyagerCore/Session.swift: the writer, off the main thread and only on change (verbatim, lines 139-173)·swift
public final class SessionWriter: @unchecked Sendable {
    private let store: SessionStore
    private let queue = DispatchQueue(label: "com.cwk.voyager.session")
    /// The document last written; touched only on `queue`.
    private var written: SessionDocument?

    public init(store: SessionStore) {
        self.store = store
    }

    /// Writes `document` soon. `failed` hears why, on the main actor, when
    /// the store refused it.
    public func write(_ document: SessionDocument, failed: @escaping @MainActor @Sendable (String) -> Void) {
        queue.async { [self] in
            do {
                try save(document)
            } catch {
                let reason = error.localizedDescription
                Task { @MainActor in failed(reason) }
            }
        }
    }

    /// Writes `document` now, after any write still waiting, and returns when
    /// it is on disk.
    public func writeNow(_ document: SessionDocument) throws {
        try queue.sync { try save(document) }
    }

    private func save(_ document: SessionDocument) throws {
        guard document != written else { return }
        try store.save(document)
        written = document
    }
}
cwkVoyager, scripts/build-app.sh: the install asks the running copy to quit with SIGTERM and waits ten seconds (verbatim, lines 221-234)·shell
# Voyager saves its session when it is asked to quit, and on SIGTERM. It is
# asked first; nothing is killed.
running_pid() { /usr/bin/pgrep -f '^/Applications/Voyager\.app/Contents/MacOS/Voyager' 2>/dev/null | /usr/bin/head -n 1 }
if [[ -n "$(running_pid)" ]]; then
  /bin/kill -TERM "$(running_pid)" 2>/dev/null || true
  for _ in {1..40}; do
    [[ -z "$(running_pid)" ]] && break
    /bin/sleep 0.25
  done
fi
if [[ -n "$(running_pid)" ]]; then
  print -u2 "HARD_FAIL Voyager is still running (a page may be holding a dialog open); quit it, then run this again"
  exit 1
fi

External links

Exercise

Take any app you have that saves a document on its own (settings, a session, a queue) and break it on purpose: write garbage into the file, start the app, and see what happens to the file. If it is replaced, change the load path so an unreadable file is moved aside under a timestamped name, the app starts with a default, and the person is told. Then make the save path refuse to overwrite a file it cannot decode. Write one test for each, using a temporary directory.
Hint
Test what is on disk afterwards, not only what the load returned: the kept file should hold exactly the damaged bytes, and a save after the move should succeed. Use a UTC stamp with no colons so the name works on every file system. If saves can happen from more than one thread, give them one serial queue now, before a fast-changing title makes you need it.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.