Files
knotchy/Sources/KnotchyCore/Session/SessionController.swift
T
aculix 9fd654298a Fix first-run setup timing out on the kernel download
On a Mac without the Linux kernel, setup ran `container system start
--enable-kernel-install` with a 120 s limit. That command downloads a
697 MB archive without printing anything, so most connections hit the
limit and the window showed `timedOut([...])`.

- Start the service with --disable-kernel-install (10 min: a first
  start still pulls the 69 MB base filesystem).
- Download the kernel in its own step with progress, then hand the file
  to `container system kernel set --tar ... --digest ...`, which checks
  it against the runtime's digest.
- Opening the setup window no longer starts the download; Set Up does.
- Timeouts and runner errors read as sentences.
- A rebuild deletes the image it replaced, which frees about 2 GB each
  time, and deletes or stops the builder VM unless it was already
  running (it kept about 2.8 GB of memory).
- Setup asks for 10 GB free, measured on a real setup.
2026-09-29 16:25:50 +05:30

960 lines
35 KiB
Swift

import Foundation
import Observation
/// The single-session state machine behind the notch UI. Inputs never block; backend calls run serially.
@MainActor
@Observable
public final class SessionController {
// MARK: - Timings and limits
public static let renderSize = PixelSize(width: 1024, height: 640)
public static let foldGrace: Duration = .milliseconds(400)
public static let neverEnteredFoldDelay: Duration = .seconds(30)
public static let defaultIdleDestroyDelay: Duration = .seconds(5 * 60)
public static let idleQuitDelay: Duration = .seconds(60)
public static let destroyedDisplay: Duration = .milliseconds(1500)
public static let endedDisplay: Duration = .seconds(3)
public static let destroyStuckDisplay: Duration = .seconds(30)
public static let noticeDisplay: Duration = .seconds(2)
public static let progressLinger: Duration = .seconds(2)
public static let minimumFreeBytes: Int64 = 2_000_000_000
public static let setupOutputLimit = 200
public static let stillBrokenMessage = "Setup finished, but the runtime still reports a problem."
// MARK: - Outputs
public private(set) var state: SessionState = .idle
// MARK: - Preferences
/// Takes effect the next time a session folds.
public var idleDestroyDelay: Duration = SessionController.defaultIdleDestroyDelay
public var opensExpanded = false
public var quitsWhenIdle = true
/// Opens the setup window; the app sets it.
@ObservationIgnored public var showSetup: @MainActor () -> Void = {}
public private(set) var setupFolded = false
@ObservationIgnored private var setupOpenedByHover = false
public private(set) var notice: Notice?
public private(set) var progress: Int?
public private(set) var openedURL: URL?
/// Reported by the VM, so untrusted: shown, never opened or retried.
public private(set) var pageURL: URL?
public private(set) var setupLog: String?
/// The latest setup action's last lines, for the setup window's details.
public private(set) var setupOutput: [String] = []
public private(set) var connection: (any StreamConnection)?
public private(set) var staleImage: StaleImage?
public private(set) var idleDeadline: ContinuousClock.Instant?
public var countdown: Duration? {
idleDeadline.map { max(.zero, $0 - clock.now) }
}
public nonisolated let liveIDs = LiveIDs()
// MARK: - Dependencies
private let backend: any VMBackend
private let connector: any StreamConnector
private let diskSpace: any DiskSpaceChecking
private let clock: any KnotchyClock
private let hostNetworks: () -> [String]
private let requestQuit: @MainActor () -> Void
private let makeSessionID: () -> String
// MARK: - Internal state
@MainActor
private final class Session {
let id: String
let url: URL
/// `backend.start` has been called, so a VM may exist.
var startBegan = false
var retired = false
var contentReady = false
var connection: (any StreamConnection)?
var tasks: [Task<Void, Never>] = []
init(id: String, url: URL) {
self.id = id
self.url = url
}
}
private enum TimerKey: Hashable, Sendable {
case foldGrace, neverEnteredFold, idleDestroy, transient, notice, progressHide, idleQuit
}
private enum RunMode: Equatable {
/// From the notch or the menu: stops at the next card, as before.
case link
/// From the setup window.
case window(continuing: Bool)
/// The setup window's check: starts the service, never builds.
case check
}
private struct SetupRun {
var mode: RunMode
/// The loop guard: each issue is fixed at most once per run.
var fixed: [SetupIssue]
var waiter: CheckedContinuation<SetupRunOutcome, Never>?
}
private let chain = BackendChain()
private let timers: DeadlineTimers<TimerKey>
// Invariants: at most one session isn't retired, and it is `current`; only `transition(to:)`
// writes `state` (and owns its timers); while `settingUp`, only the setup outcome changes it.
@ObservationIgnored private var current: Session?
@ObservationIgnored private var pendingURL: URL?
/// IDs whose destroy failed; they stay in `liveIDs`.
@ObservationIgnored private var pendingCleanup: [String] = []
@ObservationIgnored private var requestedImageUpdate: StaleImage?
@ObservationIgnored private var lastProgressAt: ContinuousClock.Instant?
@ObservationIgnored private var entered = false
@ObservationIgnored private var pointerInside = false
@ObservationIgnored private var focused = false
@ObservationIgnored private var run: SetupRun?
@ObservationIgnored private var settleWaiters: [CheckedContinuation<Void, Never>] = []
/// The issues started automatically for the link now waiting; each is tried once per request.
@ObservationIgnored private var autoFixed: (url: URL, issues: [SetupIssue])?
public init(
backend: any VMBackend,
connector: any StreamConnector,
diskSpace: any DiskSpaceChecking,
clock: any KnotchyClock = SystemClock(),
hostNetworks: @escaping () -> [String],
requestQuit: @escaping @MainActor () -> Void,
makeSessionID: @escaping () -> String = SessionController.randomSessionID
) {
self.backend = backend
self.connector = connector
self.diskSpace = diskSpace
self.clock = clock
self.hostNetworks = hostNetworks
self.requestQuit = requestQuit
self.makeSessionID = makeSessionID
timers = DeadlineTimers(clock: clock)
timers.onFire = { [weak self] key in self?.timerFired(key) }
enqueue { [backend] in await backend.sweepOrphans(keeping: []) }
timers.set(.idleQuit, after: Self.idleQuitDelay)
}
/// `knotchy-<8 hex>`: the only names the launch sweep deletes.
public nonisolated static func randomSessionID() -> String {
"knotchy-" + String(format: "%08x", UInt32.random(in: .min ... .max))
}
// MARK: - Links
/// A new link always wins. Anything but http/https shows "No web link found".
public func open(_ url: URL) {
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
guard !chain.isClosed else { return }
autoFixed = nil
if case .settingUp = state {
pendingURL = url
setupFolded = false
} else {
startSession(url)
}
}
public func open(text: String) {
guard let url = LinkIntake.extractURL(from: text) else { return show(.noWebLink) }
open(url)
}
// MARK: - Toolbar
public func navigate(_ url: URL) {
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
guard case .live = state, let connection else { return }
openedURL = url
pageURL = nil
connection.send(.navigate(url.absoluteString))
}
public func goBack() { runScript("history.back()") }
public func goForward() { runScript("history.forward()") }
public func reload() { runScript("location.reload()") }
private func runScript(_ javaScript: String) {
guard case .live = state else { return }
connection?.send(.script(javaScript))
}
// MARK: - Panel
public func expand() {
guard state == .live(.peek) else { return }
entered = true
focused = true
transition(to: .live(.expanded))
}
public func shrink() {
guard state == .live(.expanded) else { return }
transition(to: .live(.peek))
}
public func pointerEntered() {
pointerInside = true
timers.cancel(.foldGrace)
switch state {
case .settingUp:
if setupFolded { setupOpenedByHover = true }
setupFolded = false
case .starting(let step, folded: true):
unfold(to: .starting(step, folded: false))
case .live(.collapsed):
unfold(to: .live(.peek))
case .starting, .live:
entered = true
timers.cancel(.neverEnteredFold)
default:
break
}
}
public func pointerExited() {
pointerInside = false
if isPeekLike, entered, !focused {
timers.set(.foldGrace, after: Self.foldGrace)
}
if case .settingUp = state, setupOpenedByHover {
timers.set(.foldGrace, after: Self.foldGrace)
}
}
public func panelClicked() {
guard isOpenPanel else { return }
entered = true
focused = true
timers.cancel(.foldGrace)
timers.cancel(.neverEnteredFold)
}
public func clickedOutside() {
if isOpenPanel { fold() }
if case .settingUp = state { setupFolded = true }
}
public func foldSetup() {
if case .settingUp = state { setupFolded = true }
setupOpenedByHover = false
}
private var isPeekLike: Bool {
switch state {
case .starting(_, folded: false), .live(.peek): true
default: false
}
}
private var isOpenPanel: Bool {
isPeekLike || state == .live(.expanded)
}
private var isFolded: Bool {
if case .starting(_, folded: true) = state { true } else { false }
}
private func fold() {
focused = false
pointerInside = false
switch state {
case .starting(let step, folded: false):
transition(to: .starting(step, folded: true))
case .live(.peek), .live(.expanded):
transition(to: .live(.collapsed))
default:
break
}
}
private func unfold(to newState: SessionState) {
entered = true
focused = false
transition(to: newState)
}
// MARK: - Destroy, retry, dismiss
public func destroy() {
guard let session = current else { return }
switch state {
case .starting, .live:
transition(to: .destroying)
retire(session) { [weak self] gone in self?.destroyFinished(session, gone: gone) }
default:
break
}
}
public func retry() {
switch state {
case .failed(.start):
autoFixed = nil // A retry is a new request: it gets its own automatic start.
if let url = current?.url { startSession(url, pointerStays: true) }
case .failed(.destroyStuck):
guard let session = current else { return }
pendingCleanup.removeAll { $0 == session.id }
transition(to: .destroying)
enqueue { [weak self] in
await self?.runDestroy(session) { gone in self?.destroyFinished(session, gone: gone) }
}
default:
break
}
}
public func dismiss() {
switch state {
case .failed(.start):
enterIdle()
case .setupRequired:
pendingURL = nil
enterIdle()
default:
break
}
}
// MARK: - Setup
public func performSetupAction() {
guard case .setupRequired(let issue, _) = state else { return }
switch issue {
case .cliMissing, .cliOutdated:
showSetup()
case .lowDisk:
break
default:
startRun(issue, mode: .link, folded: false)
}
}
/// Rebuilds the image once this session ends.
public func requestImageUpdate() {
guard let stale = staleImage else { return }
staleImage = nil
requestedImageUpdate = stale
}
public func rebuildImage() {
switch state {
case .idle:
startRun(.imageStale(builtDaysAgo: nil), mode: .link, folded: false)
case .settingUp:
break
default:
staleImage = nil
requestedImageUpdate = StaleImage(builtDaysAgo: nil)
}
}
public func showPanel() {
switch state {
case .settingUp:
setupFolded = false
setupOpenedByHover = false
case .starting(let step, folded: true): unfold(to: .starting(step, folded: false))
case .live(.collapsed): unfold(to: .live(.peek))
default: break
}
}
public func dismissStaleImageWarning() {
staleImage = nil
}
/// Quit path: queued backend jobs never start after this. One already running may still call
/// the backend, which is safe because `destroyBlocking` then blocks the main thread until exit.
public func closeBackendChain() {
chain.close()
}
// MARK: - Setup window
/// Runs `issue`'s fix folded into the notch; when `continuing`, also each fixable issue preflight
/// finds next, each at most once. A waiting link opens only once everything is ready. nil when busy.
public func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome? {
guard acceptsSetupWindow else { return nil }
return await withCheckedContinuation { continuation in
startRun(issue, mode: .window(continuing: continuing), folded: true, waiter: continuation)
}
}
/// Preflight for the setup window: starts a stopped service or missing kernel, never builds. nil when busy.
public func checkSetup() async -> SetupRunOutcome? {
guard acceptsSetupWindow else { return nil }
let preflight = await onChain { [backend] in await backend.preflight() }
guard acceptsSetupWindow else { return nil }
switch preflight {
case .ready:
// Fixed outside Knotchy while a card held a link: open it.
if case .setupRequired = state, let url = pendingURL { startSession(url, pointerStays: true) }
return .ready
case .issue(let issue) where Self.fixes(issue, in: .check):
return await withCheckedContinuation { continuation in
startRun(issue, mode: .check, folded: true, waiter: continuation)
}
case .issue(let issue):
return .needs(issue)
}
}
/// Around a runtime install: links wait, and an update stops the service first.
public func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal> {
guard acceptsSetupWindow else {
if case .settingUp = state { return .failure(.setupRunning) }
return .failure(.sessionOpen)
}
setupLog = nil
setupOutput = []
transition(to: .settingUp(found.map { .cliOutdated(found: $0) } ?? .cliMissing))
setupFolded = true
guard found != nil else { return .success(()) }
let stopped: String? = await onChain { [backend] in
do {
try await backend.stopService()
return nil
} catch {
return Self.message(for: error)
}
}
guard let stopped else { return .success(()) }
_ = await finishRuntimeInstall(.failed(stopped))
return .failure(.stopFailed(stopped))
}
/// After an install, starts the service and builds, like `runSetup(continuing: true)`.
public func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome {
guard case .settingUp(let issue) = state, Self.isRuntimeInstall(issue), run == nil else {
return .failed(.cliMissing, "No runtime install was in progress.")
}
switch result {
case .installed:
return await withCheckedContinuation { continuation in
startRun(.serviceStopped, mode: .window(continuing: true), folded: false, waiter: continuation)
}
case .cancelled:
endHold(issue, error: nil)
return .needs(issue)
case .failed(let message):
endHold(issue, error: message)
return .failed(issue, message)
}
}
private func endHold(_ issue: SetupIssue, error: String?) {
if pendingURL != nil { transition(to: .setupRequired(issue, error: error)) } else { enterIdle() }
}
private static func isRuntimeInstall(_ issue: SetupIssue) -> Bool {
switch issue {
case .cliMissing, .cliOutdated: true
default: false
}
}
public func waitWhileSettingUp() async {
guard case .settingUp = state else { return }
await withCheckedContinuation { settleWaiters.append($0) }
}
public func runtimeStatus() async -> RuntimeStatus {
await backend.runtimeStatus()
}
/// The states where no VM exists. A stuck destroy may still have one.
private var acceptsSetupWindow: Bool {
switch state {
case .idle, .setupRequired, .failed(.start), .failed(.ended), .destroyed: true
default: false
}
}
/// After `closeBackendChain()` the job never runs and this never returns; that only happens while quitting.
private func onChain<T: Sendable>(_ job: @escaping @MainActor () async -> T) async -> T {
await withCheckedContinuation { continuation in
enqueue { continuation.resume(returning: await job()) }
}
}
// MARK: - Session lifecycle
private func startSession(_ url: URL, pointerStays: Bool = false) {
let pointerOnPanel = pointerStays && pointerInside
let startsFolded: Bool
if case .settingUp = state { startsFolded = setupFolded } else { startsFolded = false }
if let old = current { retire(old) }
let session = Session(id: makeSessionID(), url: url)
current = session
pendingURL = nil
setupLog = nil
openedURL = url
pageURL = nil
connection = nil
staleImage = nil
progress = nil
lastProgressAt = nil
entered = pointerOnPanel
pointerInside = pointerOnPanel
focused = false
for key in [TimerKey.foldGrace, .neverEnteredFold, .progressHide] { timers.cancel(key) }
liveIDs.insert(session.id)
transition(to: .starting(.booting, folded: startsFolded))
enqueue { [weak self] in await self?.runStart(session) }
}
private func retire(_ session: Session, onGone: (@MainActor (Bool) -> Void)? = nil) {
guard !session.retired else { return }
session.retired = true
for task in session.tasks { task.cancel() }
session.connection?.close()
session.connection = nil
if current === session {
connection = nil
progress = nil
timers.cancel(.progressHide)
}
enqueue { [weak self] in await self?.runDestroy(session, onGone: onGone) }
}
private func runStart(_ session: Session) async {
guard !session.retired else { return }
let preflight = await backend.preflight()
guard !session.retired else { return }
switch preflight {
case .issue(let issue):
return needsSetup(issue, for: session)
case .ready(let appRoot, let stale):
let free = await diskSpace.bytesFree(atPath: appRoot)
guard !session.retired else { return }
if let free, free < Self.minimumFreeBytes {
return needsSetup(.lowDisk(bytesFree: free), for: session)
}
if requestedImageUpdate == nil { staleImage = stale }
}
session.startBegan = true
do {
let vm = try await backend.start(id: session.id, url: session.url,
renderSize: Self.renderSize, blockCIDRs: hostNetworks())
guard !session.retired else { return } // Superseded mid-start: its destroy job runs next.
transition(to: .starting(.connecting, folded: isFolded))
session.tasks.append(Task { [weak self] in await self?.connect(session, socketPath: vm.socketPath) })
} catch {
guard !session.retired else { return }
retire(session)
transition(to: .failed(.start(Self.message(for: error))))
}
}
/// No VM exists yet, so there is nothing to destroy. Starting the service or fetching its kernel needs no password, so it just happens.
private func needsSetup(_ issue: SetupIssue, for session: Session) {
session.retired = true
liveIDs.remove(session.id)
pendingURL = session.url
guard issue == .serviceStopped || issue == .kernelMissing else {
return transition(to: .setupRequired(issue, error: nil))
}
var tried = autoFixed?.url == session.url ? autoFixed?.issues ?? [] : []
guard !tried.contains(where: { Self.sameFix($0, issue) }) else {
return transition(to: .setupRequired(issue, error: Self.stillBrokenMessage))
}
tried.append(issue)
autoFixed = (session.url, tried)
startRun(issue, mode: .link, folded: false)
}
private func connect(_ session: Session, socketPath: String) async {
let outcome: Result<any StreamConnection, any Error>
do {
outcome = .success(try await connector.connect(socketPath: socketPath))
} catch {
outcome = .failure(error)
}
guard !session.retired else {
if case .success(let late) = outcome { late.close() }
return
}
switch outcome {
case .success(let stream):
session.connection = stream
connection = stream
transition(to: .starting(.loading, folded: isFolded))
session.tasks.append(Task { [weak self] in
for await event in stream.events { self?.handle(event, from: session) }
// A stream that finishes without `.ended` has still ended.
self?.handle(.ended(.closed), from: session)
})
case .failure(StreamError.versionMismatch(let found, let need)):
retire(session)
pendingURL = session.url
transition(to: .setupRequired(.imageOutdated(found: found, need: need), error: nil))
case .failure(let error):
retire(session)
transition(to: .failed(.start(Self.message(for: error))))
}
}
private func handle(_ event: StreamEvent, from session: Session) {
guard current === session, !session.retired else { return }
switch event {
case .hello:
break
case .progress(let percent):
progressChanged(percent)
case .pageURL(let url):
pageURL = url
case .contentReady:
contentReady(session)
case .ended(let reason):
let wasStarting = if case .starting = state { true } else { false }
retire(session)
transition(to: .failed(wasStarting ? .start(Self.message(for: reason)) : .ended(reason)))
}
}
private func contentReady(_ session: Session) {
guard case .starting(_, let folded) = state, !session.contentReady else { return }
session.contentReady = true
if folded {
transition(to: .live(.collapsed))
} else if opensExpanded {
transition(to: .live(.expanded))
} else {
transition(to: .live(.peek))
if !entered { timers.set(.neverEnteredFold, after: Self.neverEnteredFoldDelay) }
}
scheduleProgressHide()
}
private func runDestroy(_ session: Session, onGone: (@MainActor (Bool) -> Void)?) async {
guard session.startBegan else {
liveIDs.remove(session.id)
onGone?(true)
return
}
do {
try await backend.destroy(id: session.id)
pendingCleanup.removeAll { $0 == session.id }
liveIDs.remove(session.id)
onGone?(true)
} catch {
if !pendingCleanup.contains(session.id) { pendingCleanup.append(session.id) }
onGone?(false)
}
}
private func destroyFinished(_ session: Session, gone: Bool) {
guard current === session, state == .destroying else { return }
transition(to: gone ? .destroyed : .failed(.destroyStuck))
}
private func enterIdle() {
openedURL = nil
pageURL = nil
progress = nil
staleImage = nil
setupLog = nil
connection = nil
entered = false
pointerInside = false
focused = false
if let update = requestedImageUpdate {
requestedImageUpdate = nil
return startRun(.imageStale(builtDaysAgo: update.builtDaysAgo), mode: .link, folded: false)
}
transition(to: .idle)
}
// MARK: - Progress bar
private func progressChanged(_ percent: Int) {
lastProgressAt = clock.now
if percent >= 100 {
progress = nil
timers.cancel(.progressHide)
} else {
progress = percent
scheduleProgressHide()
}
}
/// Hides the bar 2 s after the last PROGRESS, so a load that never reports 100 doesn't leave it up.
private func scheduleProgressHide() {
guard current?.contentReady == true, progress != nil, let last = lastProgressAt else { return }
let hideAt = last + Self.progressLinger
if hideAt <= clock.now {
progress = nil
} else {
timers.set(.progressHide, at: hideAt)
}
}
// MARK: - Setup actions
private func runSetupAction(_ issue: SetupIssue, folded: Bool = false) {
setupLog = nil
setupOutput = []
transition(to: .settingUp(issue))
if folded { setupFolded = true }
let (lines, sink) = AsyncStream.makeStream(of: String.self)
let showLines = Task { [weak self] in
for await line in lines { self?.record(line) }
}
enqueue { [weak self, backend] in
let outcome: Result<Void, any Error>
do {
try await backend.fix(issue) { sink.yield($0) }
outcome = .success(())
} catch {
outcome = .failure(error)
}
sink.finish()
await showLines.value // Every line is shown before the outcome.
await self?.setupFinished(issue, outcome)
}
}
private func record(_ line: String) {
setupLog = line
setupOutput.append(line)
if setupOutput.count > Self.setupOutputLimit {
setupOutput.removeFirst(setupOutput.count - Self.setupOutputLimit)
}
}
private func startRun(_ issue: SetupIssue, mode: RunMode, folded: Bool,
waiter: CheckedContinuation<SetupRunOutcome, Never>? = nil) {
if run != nil { endRun(.needs(issue)) } // Never happens today; it keeps "every waiter resumes once" true.
run = SetupRun(mode: mode, fixed: [issue], waiter: waiter)
runSetupAction(issue, folded: folded)
}
private func endRun(_ outcome: SetupRunOutcome) {
let waiter = run?.waiter
run = nil
waiter?.resume(returning: outcome)
}
/// Link runs leave a card, as before; window runs leave one only for a waiting link.
private func stop(_ outcome: SetupRunOutcome, card: SessionState, mode: RunMode) {
endRun(outcome)
if mode == .link || pendingURL != nil { transition(to: card) } else { enterIdle() }
}
/// A check only starts the service: the kernel is a 700 MB download, so it waits for Set Up.
private static func fixes(_ issue: SetupIssue, in mode: RunMode) -> Bool {
switch (mode, issue) {
case (.window(continuing: true), .serviceStopped), (.window(continuing: true), .kernelMissing),
(.window(continuing: true), .imageMissing), (.window(continuing: true), .imageOutdated),
(.check, .serviceStopped):
true
default:
false
}
}
/// The backend runs one command for each group, so another issue from the same group means it didn't help.
private static func sameFix(_ a: SetupIssue, _ b: SetupIssue) -> Bool {
func group(_ issue: SetupIssue) -> Int? {
switch issue {
case .imageMissing, .imageOutdated, .imageStale: 1
default: nil
}
}
guard let first = group(a) else { return a == b }
return first == group(b)
}
private func setupFinished(_ issue: SetupIssue, _ outcome: Result<Void, any Error>) async {
guard state == .settingUp(issue) else { return }
let mode = run?.mode ?? .link
if case .failure(let error) = outcome {
let message = Self.message(for: error)
// A failed "Update now" rebuild never blocks a link.
if case .imageStale = issue, let url = pendingURL {
endRun(.failed(issue, message))
return startSession(url, pointerStays: true)
}
return stop(.failed(issue, message), card: .setupRequired(issue, error: message), mode: mode)
}
switch issue {
case .imageMissing, .imageOutdated, .imageStale: requestedImageUpdate = nil
default: break
}
if mode == .link, let url = pendingURL {
endRun(.ready)
return startSession(url, pointerStays: true)
}
let preflight = await backend.preflight()
guard state == .settingUp(issue) else { return }
switch preflight {
case .ready:
endRun(.ready)
if let url = pendingURL { return startSession(url, pointerStays: true) }
enterIdle()
case .issue(let next) where mode == .link:
endRun(.needs(next))
if let url = pendingURL { return startSession(url, pointerStays: true) }
transition(to: .setupRequired(next, error: nil))
case .issue(let next) where run?.fixed.contains(where: { Self.sameFix($0, next) }) == true:
stop(.failed(next, Self.stillBrokenMessage), card: .setupRequired(next, error: Self.stillBrokenMessage), mode: mode)
case .issue(let next) where Self.fixes(next, in: mode):
run?.fixed.append(next)
runSetupAction(next)
case .issue(let next):
stop(.needs(next), card: .setupRequired(next, error: nil), mode: mode)
}
}
// MARK: - Backend chain
/// After each job, one VM left by an earlier failed destroy gets a retry, taking turns.
private func enqueue(_ job: @escaping @MainActor () async -> Void) {
chain.enqueue { [weak self] in
let stuckBefore = self?.pendingCleanup ?? []
await job()
await self?.retryPendingCleanup(among: stuckBefore)
}
}
private func retryPendingCleanup(among candidates: [String]) async {
guard let id = candidates.first(where: pendingCleanup.contains) else { return }
do {
try await backend.forceDestroy(id: id)
pendingCleanup.removeAll { $0 == id }
liveIDs.remove(id)
} catch {
// Move it to the back so a VM stuck for good doesn't starve the others.
pendingCleanup.removeAll { $0 == id }
pendingCleanup.append(id)
}
}
// MARK: - State and timers
private func transition(to newState: SessionState) {
let oldState = state
guard newState != oldState else { return }
state = newState
if case .settingUp = newState {} else {
setupFolded = false
setupOpenedByHover = false
}
if !isPeekLike { timers.cancel(.foldGrace) }
if newState != .live(.peek) { timers.cancel(.neverEnteredFold) }
if newState == .live(.collapsed) {
timers.set(.idleDestroy, after: idleDestroyDelay)
idleDeadline = timers.deadline(of: .idleDestroy)
connection?.decodeEnabled = false
} else if oldState == .live(.collapsed) {
timers.cancel(.idleDestroy)
idleDeadline = nil
if case .live = newState { connection?.decodeEnabled = true }
}
switch newState {
case .destroyed: timers.set(.transient, after: Self.destroyedDisplay)
case .failed(.ended): timers.set(.transient, after: Self.endedDisplay)
case .failed(.destroyStuck): timers.set(.transient, after: Self.destroyStuckDisplay)
default: timers.cancel(.transient)
}
if newState == .idle {
timers.set(.idleQuit, after: Self.idleQuitDelay)
} else {
timers.cancel(.idleQuit)
}
if case .settingUp(let issue) = oldState {
if case .settingUp = newState {} else { settled(after: issue) }
}
}
/// So a waiting setup window never hangs, whatever ended the setup.
private func settled(after issue: SetupIssue) {
if run != nil { endRun(.needs(issue)) }
let waiters = settleWaiters
settleWaiters = []
for waiter in waiters { waiter.resume() }
}
private func show(_ newNotice: Notice) {
notice = newNotice
timers.set(.notice, after: Self.noticeDisplay)
}
private func timerFired(_ key: TimerKey) {
switch key {
case .foldGrace:
if isPeekLike, entered, !pointerInside, !focused { fold() }
if case .settingUp = state, setupOpenedByHover, !pointerInside {
setupOpenedByHover = false
setupFolded = true
}
case .neverEnteredFold:
if state == .live(.peek), !entered { fold() }
case .idleDestroy:
destroy()
case .transient:
enterIdle()
case .notice:
notice = nil
case .progressHide:
progress = nil
case .idleQuit:
if quitsWhenIdle { requestQuit() }
}
}
nonisolated static func message(for error: any Error) -> String {
switch error {
case VMBackendError.failed(let message): message
case StreamError.protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
case StreamError.timedOut: "The VM didn't answer in time."
case StreamError.refused: "Couldn't connect to the VM."
case CLIRunner.Failure.timedOut: "The container runtime stopped responding. Try again."
case CLIRunner.Failure.notInstalled: "Apple's container runtime isn't installed."
case CLIRunner.Failure.launchFailed(let detail): "Couldn't run the container runtime: \(detail)"
default: String(describing: error)
}
}
nonisolated static func message(for reason: EndReason) -> String {
switch reason {
case .closed: "The VM closed the connection."
case .protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
case .noFirstFrame: "The VM didn't send a first frame in time."
case .decodeErrors: "The video stream couldn't be decoded."
case .socketError(let detail): "The connection to the VM failed: \(detail)."
}
}
// MARK: - Test hooks
func fireDueTimers() {
timers.fireDue()
}
func waitForBackendIdle() async {
await chain.waitUntilIdle()
}
/// Call once the session is retired: a live session's events task runs until its stream ends.
func waitForSessionTasks() async {
for task in current?.tasks ?? [] { await task.value }
}
}