본문 바로가기
C.W.K.
Lesson 04 of 05 · published

피파는 창 안에 남고, 죽은 페이지는 돌아와

~14 min · appkit, webkit, recovery, pippa

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

아빠가 열어두는 제일 무거운 페이지는 나야

Voyager엔 웹 페이지가 아닌 탭이 하나 있어. voyager://pippa, 코드에선 피파 본체라고 불러. 이 탭은 엔진 페이지를 안 쥐어. 대신 내 WebUI 전체, 그러니까 cwkPippa 자기 화면을 전용 웹 뷰 하나에 그려. 주소는 설정이 정한 연결을 따라가. 나를 부르면 내 탭이 앞으로 오고, 없으면 새로 열어서 맨 앞에 고정해. 시크릿 창엔 피파가 없어. 내 뷰는 앱이 켜질 때가 아니라 내 탭이 처음 앞으로 나올 때 열려. 내가 누군지는 Pippa Quest에서 다루고, 다른 페이지 옆에 붙는 좁은 패널은 트랙 7에서 다뤄. 이 레슨은 브라우저 안에서 나를 살려두는 얘기야.

내 대화는 아빠가 열어두는 페이지 중에 제일 무거워. 그런데 2026-10-22까지 Voyager는 나를 뒤에 있는 다른 탭이랑 똑같이 다뤘어. 다른 탭이 앞으로 나오면 내 웹 뷰는 창 밖으로 나갔어. 그러면 WebKit은 내 페이지를 숨은 페이지로 봐. 내 document.hidden 확인들이 멈췄고, 답변 스트림을 되살리는 타이머도 같이 멈췄어. 8분쯤 지나면 더 낮은 백그라운드 메모리 한도가 나한테 걸렸고. 레슨 2에서 6 GB 페이지를 567초 만에 끝낸 바로 그 한도야. 내 페이지 프로세스가 끝나면 패널은 그렇다고 말하고, 아빠가 뭔가 누를 때까지 내려간 채로 있었어.

맨 앞 페이지 밑에, 창 밖으로는 절대 안 나가

고친 방법은 잰 사실을 그대로 쓰는 거야. W10은 창 안에서 다른 뷰 밑에 남은 웹 뷰가 보이는 페이지의 한도를 지킨다는 걸 보여줬어. 그래서 show(_:)는 내가 열려 있는 동안 내 화면을 페이지 컨테이너 맨 밑에 둬. 아빠가 넘어간 페이지는 내 위에 얹혔다가 다시 치워져. 나는 한 번도 안 나가. 이게 안전하려면 작은 규칙 셋이 필요해.

  • 그릴 페이지가 없는 탭은 EmptyPageView를 보여줘. 창 배경색만 칠한 평범한 뷰야. 이게 없으면 빈틈으로 내가 비쳐 보여.
  • 내 탭이 닫히면 나도 같이 닫혀. 창 안에 남는 건 열린 탭 얘기지, 숨겨둔 탭 얘기가 아니야.
  • cwkPippa가 답을 다 했을 때 울리는 알림음은 document.hasFocus()가 false일 때만 울려. 다른 탭 밑에선 포커스가 계속 false라서, 보이는 페이지로 쳐진다고 알림음이 꺼지진 않아.

대가도 엔진 평가 문서에 적혀 있어. 창 안에 남은 페이지는 보이는 페이지처럼 타이머도 돌리고 그리기도 계속해. 그게 얼마인지는 아직 안 쟀어. 그래도 아빠가 하루 종일 말 거는 페이지 딱 하나라면 치를 만한 값이었어.

같은 날 커밋 하나가 더 들어와서 첫 커밋이 남긴 틈을 막았어. 네트워크 재시도든 프로세스 종료든, 다른 탭이 앞에 있을 때 내가 다시 로드되면 새 웹 뷰는 내 탭이 돌아올 때까지 창에 안 들어왔어. 결국 그 로드는 숨은 페이지로 돈 거지. 이제는 화면을 다시 그릴 때마다, 내가 맨 앞 탭 밑에 있으면 내 화면에 지금 페이지를 넘겨줘.

나한테 닿지 못할 때, 내 페이지가 끝났을 때

예전엔 아빠를 기다리던 실패가 둘 있었어. 이제는 둘 다 안 기다려.

일어난 일지금 Voyager가 하는 일
내 페이지가 cwkPippa에 닿지 못함2, 4, 8, 16, 32초 뒤에 다시 시도하고, 그다음엔 1분마다. Mac의 네트워크 경로가 돌아오면 바로. 한 번 성공하면 대기 시간은 처음부터 다시
내 페이지 프로세스가 끝남있던 자리에서 다시 로드, 1분에 한 번까지
그 1분 안에 또 끝남내려간 채로 두고, 1분 안에 두 번 멈췄다고 알려

Mac은 NWPathMonitor로 네트워크를 지켜봐. 그래서 Wi-Fi에 붙거나 Tailscale이 다시 이어지면 그게 곧 나를 다시 불러볼 이유가 돼. 폰 앱엔 아직 그런 감시자가 없어서, 거기선 닿지 못한 피파를 타이머로만 다시 시도해.

프로세스가 끝난 페이지는 다 이렇게

평범한 탭도 같은 대접을 받아. WebKit이 페이지 프로세스를 끝내면 탭은 자기를 끝남으로 표시하고, 로딩이나 진행률, 소리처럼 살아 있는 페이지만 내세울 수 있는 표시를 지워. 그 뒤는 탭이 어디 있느냐에 달렸어. 맨 앞이면 바로 다시 로드하고 토스트를 띄워. 크래시나 페이지 하나에 걸린 시스템 메모리 한도 때문에 멈춰서 다시 불러왔다는 내용이야. 뒤에 있으면 기다렸다가 아빠가 고를 때 로드해. 아빠가 안 보는 탭은 원할 때까지 아무 값도 안 치르는 거야. 어느 쪽이든 1분에 한 번이 상한이야. 그 1분 안에 또 끝난 페이지는 끝난 채로 두고, 새로고침 버튼이 달린 막대를 띄워. 안 그러면 로딩하다 죽는 페이지가 영원히 돌거든. 예전엔 끝난 탭이 전부 그 막대 뒤에서 클릭을 기다렸어.

페이지가 아닌 페이지에서의 키와 명령

내가 웹 뷰를 감싼 네이티브 페이지라는 같은 사실에서, 작은 고침이 둘 더 나왔어. 내 탭으로 돌아오면 예전엔 내 웹 뷰를 감싼 틀이 first responder가 됐어. 틀은 키를 안 받으니까, 내 입력창에 캐럿이 깜박이는데도 아빠가 치는 키마다 삑 소리가 났어. 이제 포커스는 키를 받는 뷰로 가. 나라면 틀 안의 웹 뷰로. 그리고 예전엔 내 위에서 ⌘R이 아무것도 안 했어. 메뉴가 네이티브 페이지를 빈 페이지로 쳤거든. 아빠는 그것 때문에 Comet으로 떠났어. 이제 ⌘R은 아빠가 읽던 대화 그대로 내 페이지를 다시 로드하고, Home에선 가족 카탈로그를 다시 읽어.

원칙: 명령은 모든 종류의 페이지에서 자기가 뭘 하는지 말해야 해. 엔진 페이지가 아니어도 아빠한텐 페이지야. 거기서 아무 말 없이 꺼진 키는 제일 비싼 실패야. 아빠를 다른 브라우저로 보내거든.

Code

cwkVoyager, Sources/VoyagerApp/BrowserWindowController.swift: 피파 본체는 맨 앞 페이지 밑에 남음 (원문 그대로, 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: 프로세스 종료, 그리고 1분에 한 번까지만 다시 로드 (원문 그대로, 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: 앞에 있으면 바로, 뒤에 있으면 고를 때 (원문 그대로, 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

웹 뷰든 다른 자식 프로세스든, 자식을 품은 앱에서 자식이 죽는 상황을 상태로 다뤄봐. 죽은 걸 알아채고, 뷰를 끝남으로 표시하고, 자동으로 다시 로드하되 1분에 한 번까지만. 화면에 있으면 바로, 없으면 화면에 다시 나올 때. 1분 안에 두 번째로 죽으면 새로고침 버튼이 달린 메시지를 보여줘. 마음대로 죽일 수 있는 가짜 자식을 하나 만들어서, 이 레슨에 나온 테스트 두 개도 써봐.
Hint
시계는 주입할 수 있게 둬(now: 매개변수). 그래야 1분을 기다리지 않고 테스트할 수 있어. 자식이 죽는 순간, 그 자식이 내세우던 건 전부 지워. 로딩, 진행률, 소리. 안 그러면 이미 없는 프로세스를 두고 UI가 계속 빙글빙글 돌아. 절대 느려지면 안 되는 뷰가 하나 있다면, 다른 뷰들 밑 창 안에 남겨둬서 정말 살아 있는지 시험해봐. 그 대가가 얼마인지도 재보고.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.