Every open passes through a real loading window — the walk moves off the main actor

Phase 2 of the decision surface: the pre-snapshot loading state ruled
2026-07-29 (02 § Launch and window lifecycle), built. The board window
appears immediately at its saved frame, titled with the registry
record's cached name, its content a centered spinner behind an
injectable ~200ms grace — no skeletons, and the first snapshot snaps in
place. The tree walk runs off-main via BoardStoreRegistry.acquireOffMain
(per-board single-flight keyed by file identity — concurrent opens of
one root share a walk, restoration of many boards is genuinely
parallel), landing in BoardStore's new designated init(rootURL:loaded:);
the self-walking init survives as a convenience for its ~470 callers.

⌘W during the walk is real: configureWindow split into a loading half
(frame restore, frame tracking, close interception — installed before
the walk) and a store half (toolbar, widget, hideTitle, undo — installed
at the snap), and the walk lives in an explicitly held BoardOpenWalk so
the user's close and SwiftUI's teardown end in one cancel().
Cancellation is discard-on-completion: nil from acquireOffMain means
nothing was built, nothing retained, and no open-now flag was ever set.
Failure keeps today's sequence exactly: record the launch failure,
welcome's row carries it, the window retires.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 09:53:26 -04:00
parent ba1726fa77
commit 0933ac1b01
8 changed files with 795 additions and 41 deletions
+136 -9
View File
@@ -106,6 +106,25 @@ public final class BoardStoreRegistry {
/// it already has. A `[URL: Entry]` here would be a latent bug with a plausible shape.
private var entries: [FileIdentity: Entry] = [:]
/// **The open walk's single flight** one board, one tree walk, however many windows ask for it
/// at once (02-architecture.md § Launch and window lifecycle: "Restoration is parallel each
/// walk independent").
///
/// Keyed by the same `FileIdentity` the entries are, which is the whole reason it lives here
/// rather than in a caller: the registry already knows that two differently-spelled URLs are one
/// board. Restoration racing a Finder open of the same root, or a board window and its restored
/// card window arriving together, therefore costs **one** walk and produces **one** store the
/// prior art is the store's own reload coalescing (`requestReload`), applied to the load that
/// happens before a store exists to coalesce on.
///
/// **Per board, never global**: a slow network board's walk holds nothing but its own key, so
/// every other board opening at the same moment proceeds untouched.
///
/// The task's failure type is `Never` and its value is a `Result` `Task`'s typed-throws
/// initializers do not exist, and widening `BoardLoadFailure` to `any Error` on the way through
/// the join would lose exactly the aggregate the decision surface is made of.
private var walksInFlight: [FileIdentity: Task<Result<LoadResult, BoardLoadFailure>, Never>] = [:]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store-registry")
/// The app owns one instance and passes it down; there is no shared singleton to reach for.
@@ -117,7 +136,15 @@ public final class BoardStoreRegistry {
// MARK: - Acquire / release
/// The store for `rootURL`, opening the board if this is the first window to ask for it.
/// The store for `rootURL`, opening the board if this is the first window to ask for it
/// **walking on the caller's own actor**.
///
/// The synchronous acquire, and still the right one for every caller that cannot render anything
/// before the board exists: a card window joining a board that is already open (the walk never
/// runs there), the storeless surfaces, and the tests. A **board window** takes
/// `acquireOffMain(_:)` instead 02's pre-snapshot loading state gave that caller something to
/// show while the walk runs, which is the only reason a walk on the main actor was ever
/// tolerable.
///
/// **First acquire**: loads the board (fail-fast the `BoardLoadFailure` is rethrown untouched,
/// every collected defect included, because the decision surface is the caller's to host
@@ -138,20 +165,120 @@ public final class BoardStoreRegistry {
/// A failed load leaves **nothing behind**: no entry, no watcher, no count. A board that failed
/// to open is not open.
public func acquire(_ rootURL: URL) throws(BoardLoadFailure) -> BoardStore {
if let identity = FileIdentity(of: rootURL), var entry = entries[identity] {
entry.referenceCount += 1
entries[identity] = entry
Self.logger.debug("acquire: existing board, \(entry.referenceCount, privacy: .public) references")
return entry.store
if let store = referenceExistingBoard(at: rootURL) { return store }
return try adopt(BoardStore(rootURL: rootURL), rootURL: rootURL)
}
/// The **board window's** acquire: the tree walk runs off the main actor, and concurrent asks
/// for one board share it (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29).
///
/// ### What it does, in the order it does it
///
/// 1. **An already-open board returns immediately**, refcount bumped, no walk `acquire`'s own
/// first act, unchanged and still synchronous: this is the card-window and second-window path
/// and it must not cost a suspension, let alone a directory walk.
/// 2. **Otherwise one walk**, on a detached task at `.userInitiated`, joined by every other
/// async acquire of the same board that arrives while it runs (`walksInFlight`).
/// 3. **The result lands back here on the main actor** and is turned into the store through
/// the same `adopt` the synchronous path uses, so the probe, the watcher, the brackets, the
/// bookmark, the entry and the heals are the same wiring by construction rather than by two
/// lists that must be kept in step.
///
/// ### Cancellation is discard-on-completion
///
/// W during the pre-snapshot loading state cancels the *caller's* task ("the walk is
/// cancellable: W during loading cancels it and closes the window"). The walk itself is one
/// synchronous `BoardLoader.load` call and is deliberately **not** made cooperatively
/// cancellable: the loader would have to check a flag between every directory and every parse,
/// and the reward would be a fraction of a second of a walk nobody is waiting for. So the window
/// closes at once and the walk's tail is wasted work accepted, and the whole of the cost.
///
/// What cancellation *does* guarantee is that the discarded walk leaves nothing behind: this
/// returns `nil` before constructing anything, so there is no store, no watcher, no entry and no
/// reference and the caller's security-scoped access is its own to release.
///
/// - Returns: the board's store, or `nil` if this acquire was cancelled before its walk landed.
/// `nil` is not a failure: nothing went wrong and nothing was opened.
public func acquireOffMain(_ rootURL: URL) async throws(BoardLoadFailure) -> BoardStore? {
if let store = referenceExistingBoard(at: rootURL) { return store }
// `nil` for a root that does not exist or whose volume will not answer there is nothing to
// coalesce on, so such an open walks alone and the loader produces the honest error for it.
let identity = FileIdentity(of: rootURL)
let walk: Task<Result<LoadResult, BoardLoadFailure>, Never>
if let identity, let joined = walksInFlight[identity] {
Self.logger.debug("acquire: joining the walk already running for this board")
walk = joined
} else {
walk = Self.walk(rootURL)
if let identity { walksInFlight[identity] = walk }
}
let store = try BoardStore(rootURL: rootURL)
let outcome = await walk.value
// Cleared by whichever joiner resumes first; the equality check is what stops a *later*
// walk of the same board from being cleared by a straggler from the previous one.
if let identity, walksInFlight[identity] == walk { walksInFlight[identity] = nil }
// Discard-on-completion. Before the store is constructed, so a cancelled open leaves the
// registry exactly as it found it.
if Task.isCancelled {
Self.logger.debug("acquire: cancelled before its walk landed — nothing opened")
return nil
}
// A joiner that resumed first has already built the board; this is the same identity hit as
// step 1, asked again because the world moved while this call was suspended. There is no
// suspension between here and the entry's insertion in `adopt`, so two joiners cannot both
// pass it.
if let store = referenceExistingBoard(at: rootURL) { return store }
switch outcome {
case let .failure(failure):
throw failure
case let .success(result):
return try adopt(BoardStore(rootURL: rootURL, loaded: result), rootURL: rootURL)
}
}
/// One tree walk on a task of its own. `Task.detached` rather than `Task { }` for
/// `BoardStore.startReload`'s reason exactly: a task created inside a `@MainActor` method
/// inherits that isolation and would run the walk on the main actor, which is the whole thing
/// this is avoiding.
private static func walk(_ rootURL: URL) -> Task<Result<LoadResult, BoardLoadFailure>, Never> {
Task.detached(priority: .userInitiated) {
do throws(BoardLoadFailure) {
return .success(try BoardLoader.load(boardRoot: rootURL))
} catch {
return .failure(error)
}
}
}
/// The already-open answer, shared by both acquires: the store this board already has, with this
/// caller's reference added. `nil` means "not open here", which is the only case that walks.
private func referenceExistingBoard(at rootURL: URL) -> BoardStore? {
guard let identity = FileIdentity(of: rootURL), var entry = entries[identity] else { return nil }
entry.referenceCount += 1
entries[identity] = entry
Self.logger.debug("acquire: existing board, \(entry.referenceCount, privacy: .public) references")
return entry.store
}
/// Everything a freshly loaded board needs before it is anyone's to show the probe, the
/// watcher, the wiring in both directions, the bookmark, the entry, the heals.
///
/// **One body for both acquires.** How the store was built (a walk on this actor, or a walk that
/// landed from a detached task) is the only difference between the two paths; everything from
/// here down is identical, and identical by sharing rather than by resemblance.
private func adopt(_ store: BoardStore, rootURL: URL) throws(BoardLoadFailure) -> BoardStore {
// **The open-time writability probe** (02-architecture.md § Write-failure surfacing, "An
// unwritable board location enters the read-only lock at open"), and this is the seam for
// it: every way a board opens welcome's recents, a Finder open, File Open, restoration,
// a card window arriving first funnels through `acquire`, so the probe is wired once here
// instead of at each caller, and no future open path can forget it.
// a card window arriving first funnels through one of the two acquires and so through
// this method, so the probe is wired once here instead of at each caller, and no future open
// path can forget it.
//
// **First, immediately after the load.** The lock has to be standing before anything else
// in this method can act on the board, and two things below would otherwise write into a