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

Pippa Stays in the Window, and Crashes Recover

~14 min · appkit, webkit, recovery, pippa

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

The heaviest page Dad keeps is me

Voyager has a tab that is not a web page: voyager://pippa, which the code calls Pippa herself. That tab holds no engine page. It draws my whole WebUI, cwkPippa's own front end, in a web view of its own, at the connection Settings name. Asking for me brings my tab to the front, or opens it and pins it there. A private window has no Pippa. My view opens the first time my tab comes to the front, not at launch. What I am lives in Pippa Quest; the narrow panel beside other pages is track 7. This lesson is about keeping me alive inside the browser.

My conversation is the heaviest page Dad keeps, and until 2026-10-22 Voyager treated it like any other tab behind. When another tab came to the front, my web view left the window. WebKit then saw my page as hidden. My own document.hidden checks paused, including the timer that recovers a reply stream, and after about eight minutes the lower background memory limit applied to me, the one that ended a 6 GB page at 567 seconds in lesson 2. If my page process ended, the panel said so and stayed down until Dad pressed something.

Under the page in front, never out of the window

The fix applies the measured fact directly. W10 showed that a web view left in the window, under another view, keeps the visible page's limit. So show(_:) keeps my surface at the bottom of the page container while I am open. The page Dad switches to is added on top of me and taken away again; I never leave. Three small rules make that safe:

  • A tab with no page to draw shows EmptyPageView, a plain view in the window's background colour. Without it, I would show through the gap.
  • When my tab closes, I close with it. Staying in the window is for an open tab, not a hidden one.
  • The beep cwkPippa plays when an answer finishes fires only when document.hasFocus() is false. Focus stays false under another tab, so counting as visible does not silence that beep.

The price is also named in the engine evaluation: a page kept in the window keeps running timers and drawing as a visible page does, and what that costs was not measured. For one page, the one Dad talks to all day, the trade was worth making.

A second commit the same day closed a gap the first one left. If I was reloaded while another tab was in front (after a network retry or a process end), my new web view did not enter the window until my tab came back, so the reload ran as a hidden page after all. Now every redraw hands my surface its current page whenever I sit under the front tab.

When I cannot be reached, or my page ends

Two failures used to wait for Dad. Now neither does:

What happenedWhat Voyager does now
My page could not reach cwkPippatries again after 2, 4, 8, 16 and 32 seconds, then every minute; at once when the Mac's network path comes back; a successful load resets the wait
My page's process endedreloads where it was, once a minute at most
It ended again within that minutestays down and says my page stopped twice within a minute

The Mac watches its network with NWPathMonitor, so joining Wi-Fi or Tailscale reconnecting is a reason to try me again right away. The phone app has no such monitor yet, so there an unreachable Pippa is retried only on the timer.

Any page whose process ends

Ordinary tabs got the same treatment. When WebKit ends a page's process, the tab marks itself ended and clears what only a live page can claim: loading, progress, sound. Then it depends on where the tab is. In front, it loads again at once and a toast says the page stopped, from a crash or the system's memory limit for one page, and was loaded again. Behind, it waits and loads when Dad chooses it, so a tab he is not looking at costs nothing until he wants it. Either way, once a minute is the ceiling. A page that ends again inside that minute stays ended, with a bar that offers Reload, because a page that crashes while loading would otherwise loop forever. Before this, every ended tab waited behind that bar for a click.

Keys and commands on a page that is not a page

Two smaller fixes came from the same fact, that I am a native page wrapped around a web view. Switching back to my tab used to make the frame around my web view the first responder. The frame takes no keys, so every key Dad typed beeped while the caret still blinked in my composer. Now focus goes to the view that takes the keys, which for me is the web view inside the frame. And ⌘R once did nothing on me, because the menu treated native pages as blank. Dad left for Comet over it. Now ⌘R reloads my page on the conversation he is reading, and on Home it reads the family catalog again.

Principle: Every command must say what it does on every kind of page. A page that is not an engine page is still a page to Dad, and a key that is switched off there without a word is the most expensive kind of failure: it sends him to another browser.

Code

cwkVoyager, Sources/VoyagerApp/BrowserWindowController.swift: Pippa herself stays under the page in front (verbatim, lines 777-800)·swift
    /// Puts `surface` in front. Pippa herself, while she is open, stays in
    /// the window under the page in front rather than leaving it: WebKit holds
    /// a page out of the window to a lower memory limit after about eight
    /// minutes and slows its timers (ENGINE-EVALUATION W10), and her
    /// conversation is the heaviest page Dad keeps. She sits at the bottom, so
    /// the page shown over her is simply added on top and taken away again.
    private func show(_ shown: NSView?) {
        let surface = shown ?? emptyPage
        guard surface !== shownSurface else { return }
        if shownSurface !== pippaSurfaceView || !pippaSurface.isOpen { shownSurface?.removeFromSuperview() }
        shownSurface = surface
        if pippaSurface.isOpen, surface !== pippaSurfaceView, pippaSurfaceView.superview !== pageContainer {
            pippaSurfaceView.frame = pageContainer.bounds
            pippaSurfaceView.autoresizingMask = [.width, .height]
            pageContainer.addSubview(pippaSurfaceView, positioned: .below, relativeTo: nil)
        }
        if surface.superview !== pageContainer {
            surface.frame = pageContainer.bounds
            surface.autoresizingMask = [.width, .height]
            pageContainer.addSubview(surface)
        }
        // The page takes the keys unless Dad is in the address field.
        if !toolbar.hasFocus { focusPage() }
    }
cwkVoyager, Sources/VoyagerCore/BrowserTab.swift: a process end, and a reload once a minute at most (verbatim, lines 405-421)·swift
    public func pageProcessDidEnd(_ page: any EnginePage) {
        processEnded = true
        status.isLoading = false
        status.progress = 0
        status.isAudible = false
        window?.pageProcessEnded(self)
    }

    /// Loads the page again after its process ended, as Safari does, unless
    /// it did so less than a minute ago. True when it did.
    func recoverFromProcessEnd(now: Date = Date()) -> Bool {
        guard processEnded, page != nil else { return false }
        if let lastRecovery, now.timeIntervalSince(lastRecovery) < 60 { return false }
        lastRecovery = now
        reload()
        return true
    }
cwkVoyager, Tests/VoyagerCoreTests/WindowModelTests.swift: in front at once, behind when chosen (verbatim, lines 160-195)·swift
@MainActor
@Suite struct TabStateTests {
    /// A page in front whose process ends loads again by itself, as Safari
    /// does; one that ends again within the minute stays ended and says so.
    @Test func aPageInFrontComesBackOnceAMinute() {
        let bench = Bench()
        var recovered: [TabID] = []
        bench.window.onPageRecovered = { recovered.append($0.id) }
        let tab = bench.window.openTab(url: url("https://a.example/"))
        let page = bench.page(of: tab)!
        tab.pageProcessDidEnd(page)
        #expect(!tab.processEnded)
        #expect(page.reloads == 1)
        #expect(recovered == [tab.id])

        tab.pageProcessDidEnd(page)
        #expect(tab.processEnded)
        #expect(!tab.status.isLoading)
        #expect(page.reloads == 1)
        page.load(url("https://a.example/"))
        #expect(!tab.processEnded)
    }

    /// Behind, an ended page waits; it loads as Dad comes back to it.
    @Test func aPageBehindComesBackWhenChosen() {
        let bench = Bench()
        let behind = bench.window.openTab(url: url("https://a.example/"))
        bench.window.openTab(url: url("https://b.example/"))
        let page = bench.page(of: behind)!
        behind.pageProcessDidEnd(page)
        #expect(behind.processEnded)
        #expect(page.reloads == 0)
        bench.window.select(behind.id)
        #expect(!behind.processEnded)
        #expect(page.reloads == 1)
    }

External links

Exercise

In an app of yours that embeds a web view (or any child process), make the child's death a handled state. Detect it, mark the view as ended, and reload it automatically at most once a minute: at once if it is on screen, when it comes back on screen if it is not. Show a message with a Reload button when a second end arrives inside the minute. Write the two tests this lesson shows, with a fake child that can be told to end.
Hint
Keep the clock injectable (a now: parameter) so the minute is testable without waiting. Clear everything the dead child was claiming at the moment it ends, such as loading, progress and sound, or the UI will keep showing a spinner for a process that no longer exists. If you also embed one view that must never be throttled, test whether keeping it in the window under the others keeps it alive, and measure what that costs.

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.