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:
@@ -589,8 +589,12 @@ public final class BoardStore: HealHost {
|
||||
/// banner, the lock, "a failed reload never replaces a good snapshot" — exists only *because*
|
||||
/// this one succeeded.
|
||||
///
|
||||
/// The walk is synchronous because the caller has nothing to render until it lands; the
|
||||
/// asynchronous, off-main pipeline starts with the first reload.
|
||||
/// The walk runs here, on whatever actor the caller is on — the storeless shape, and the one
|
||||
/// every test and every synchronous consumer uses. The **window's** open no longer takes it:
|
||||
/// 02-architecture.md's pre-snapshot loading state (ruled 2026-07-29) gave the caller something
|
||||
/// to render before the snapshot exists, which retired this init's old rationale ("the caller
|
||||
/// has nothing to render until it lands"), so `BoardStoreRegistry.acquireOffMain` walks off the
|
||||
/// main actor and hands the result to `init(rootURL:loaded:)` below.
|
||||
///
|
||||
/// **It writes nothing, the opened board's defects included.** `defects` is recorded here and
|
||||
/// acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
|
||||
@@ -598,8 +602,23 @@ public final class BoardStore: HealHost {
|
||||
/// with a reload behind it rather than a write into a board nothing is watching yet. A store
|
||||
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
|
||||
/// thereafter.
|
||||
public init(rootURL: URL) throws(BoardLoadFailure) {
|
||||
public convenience init(rootURL: URL) throws(BoardLoadFailure) {
|
||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
||||
self.init(rootURL: rootURL, loaded: result)
|
||||
}
|
||||
|
||||
/// The same board, from a walk that already happened somewhere else.
|
||||
///
|
||||
/// **The designated init, and the only one that is not a load**: `LoadResult` is `Sendable` and
|
||||
/// `BoardLoader` is stateless statics, so the walk can run anywhere and only this assignment has
|
||||
/// to be on the main actor — exactly the split `startReload` has used for every reload since the
|
||||
/// beginning, now available to the *first* load too. It cannot fail, because failing is the
|
||||
/// walk's job and the walk is over: a caller holding a `LoadResult` holds a board that loaded.
|
||||
///
|
||||
/// `rootURL` is passed rather than read off `result.model` for the reason the property's own doc
|
||||
/// comment gives — the store's root follows an absorbed rename ahead of the snapshot that will
|
||||
/// carry it.
|
||||
public init(rootURL: URL, loaded result: LoadResult) {
|
||||
self.rootURL = rootURL
|
||||
self.snapshot = result.model
|
||||
self.loadWarnings = result.warnings
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user