Surface write failures — banners, locks, one modal
The banner surface as one vocabulary (Kanban/LiveStore/BannerCenter, Kanban/UI/BannerStripView): a pure precedence rule — in-progress pinned above the collapse (ratified mid-build), lock > breakage > one-shot write failures > commit+attachment, signposts last — with all user-facing phrasing owned here via exhaustive switches over the closed WriteOperation enum; free-form English survives only in diagnostics. performWrite posts its failures before rethrowing, so no one-shot can bypass the strip; refusals under lock post nothing. The lock vocabulary completes: vanishedRoot and unwritableLocation join bracketedReloadFailed, each with its own clearing rule (unwritable clears only on a reconciling reload's writability re-probe). The registry now owns root recovery: bookmark re-resolution absorbs renames transparently, a dead root locks read-only and re-arms FSEvents on the gone path so the root's return round-trips back through rootChanged, re-minting and re-keying on the way. DirtyBufferGuard is the one modal moment, retry / save a copy / discard, no fourth button. 36 new tests; full suite 333 tests in 62 suites green. Five findings filed on the Redesign board. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// What happens to an open board when its folder moves, disappears, or comes back — the settled
|
||||
/// root-identity rules of 02-architecture.md § Write-failure surfacing, exercised end to end
|
||||
/// against a **real FSEvents stream, a real registry, and a real bookmark**.
|
||||
///
|
||||
/// A fake would prove nothing here. The three claims under test are all claims about the
|
||||
/// filesystem's actual behaviour: that a bookmark follows a rename, that FSEvents reports the
|
||||
/// *creation* of a path it was watching before that path existed, and that a `access(2)` probe is
|
||||
/// what distinguishes "this board loads fine" from "this board can be written to". Every one of
|
||||
/// them would be assumed rather than tested against a double.
|
||||
///
|
||||
/// The flakiness that buys is handled the way `FolderWatcherTests` handles it: **waiting for
|
||||
/// something is generous** (poll for seconds — a slow machine must not fail a correctness test)
|
||||
/// and nothing is asserted from an elapsed interval. The one test that needs no filesystem timing
|
||||
/// at all — the writability clearing rule — drives the store's inbound door directly instead, so
|
||||
/// its ordering is exact rather than merely likely.
|
||||
|
||||
// MARK: - Support
|
||||
|
||||
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||
|
||||
/// A small board: two lanes, one card. Enough that a reload landing at a new root has something
|
||||
/// recognisable in it.
|
||||
@MainActor
|
||||
private func makeBoard(at root: URL) throws {
|
||||
let manager = FileManager.default
|
||||
try manager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
func write(_ relativePath: String, _ text: String) throws {
|
||||
let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
|
||||
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try Data(text.utf8).write(to: folder.appendingPathComponent("index.md"))
|
||||
}
|
||||
|
||||
try write("", Item.board)
|
||||
try write(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try write("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
try write(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try makeBoard(at: fixture.root)
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// Adds a card by hand — a *foreign* write by construction: no Writer, no bracket, exactly what an
|
||||
/// agent or an editor does.
|
||||
private func writeCard(inBoard root: URL, lane: String, id: String, title: String, order: String) throws {
|
||||
let folder = root.appendingPathComponent(lane, isDirectory: true).appendingPathComponent(id, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try Data(Item.rich(order: order, title: title).utf8).write(to: folder.appendingPathComponent("index.md"))
|
||||
}
|
||||
|
||||
private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] {
|
||||
(snapshot.lanes.first { $0.id.rawValue == id }?.cards ?? []).compactMap(\.title.value)
|
||||
}
|
||||
|
||||
/// The one shape a path comparison may take here. A bookmark resolves to the canonical location
|
||||
/// (`/private/var/...`) while `FileManager.temporaryDirectory` hands out the symlinked one
|
||||
/// (`/var/...`), so raw `URL` equality would fail on a board that relocated perfectly.
|
||||
private func canonical(_ url: URL) -> String {
|
||||
url.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
}
|
||||
|
||||
/// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is
|
||||
/// not a bounded-latency promise. A root change in particular can take several seconds to surface.
|
||||
@MainActor
|
||||
private func waitUntil(_ deadline: Duration = .seconds(15), _ condition: () -> Bool) async {
|
||||
let start = ContinuousClock.now
|
||||
while ContinuousClock.now - start < deadline {
|
||||
if condition() { return }
|
||||
try? await Task.sleep(for: .milliseconds(25))
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives a freshly started stream a beat to register with `fseventsd`, so the first change a test
|
||||
/// makes cannot land in the window between `FSEventStreamStart` and the stream actually being live.
|
||||
@MainActor
|
||||
private func settle() async {
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("Root recovery")
|
||||
struct RootRecoveryTests {
|
||||
|
||||
// MARK: Rename absorption
|
||||
|
||||
@Test("A rename is absorbed transparently: new root, no banner, no lock, wiring intact")
|
||||
func renameIsAbsorbed() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let renamed = fixture.root
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true)
|
||||
// Registered first so it runs *last*: the store is released — and its watcher stopped —
|
||||
// before the folder it is watching is removed.
|
||||
defer { try? FileManager.default.removeItem(at: renamed) }
|
||||
|
||||
let registry = BoardStoreRegistry()
|
||||
let store = try registry.acquire(fixture.root)
|
||||
defer { registry.release(store) }
|
||||
await settle()
|
||||
|
||||
// A Finder rename, which 01-storage-format.md calls ordinary. The board is the *file*, not
|
||||
// the string that names it.
|
||||
try FileManager.default.moveItem(at: fixture.root, to: renamed)
|
||||
|
||||
// The done-when: the store's URLs re-derived, and a reload actually ran at the new root
|
||||
// (`snapshot.rootURL` is the root the last successful walk used).
|
||||
await waitUntil { canonical(store.snapshot.rootURL) == canonical(renamed) }
|
||||
#expect(canonical(store.rootURL) == canonical(renamed))
|
||||
#expect(canonical(store.snapshot.rootURL) == canonical(renamed))
|
||||
|
||||
// Nothing was ever wrong, and the strip says so.
|
||||
#expect(store.readOnlyLock == nil)
|
||||
#expect(store.reloadFailure == nil)
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
#expect(store.snapshot.lanes.count == 2, "the board is still the board")
|
||||
|
||||
// The entry followed too: identity did not change, so the same store answers for the new
|
||||
// path and a window opening it would share rather than duplicate.
|
||||
#expect(registry.liveStore(for: renamed) === store)
|
||||
#expect(registry.openBoardCount == 1)
|
||||
|
||||
// And the wiring survived the move: a foreign edit at the *new* path reloads.
|
||||
try writeCard(inBoard: renamed, lane: Ident.lane1, id: Ident.card2, title: "Second", order: "2048")
|
||||
await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Second") }
|
||||
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second"])
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Vanish and return
|
||||
|
||||
@Test("A vanished root locks the board read-only, and the root's return clears it")
|
||||
func vanishAndReturn() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let registry = BoardStoreRegistry()
|
||||
let store = try registry.acquire(fixture.root)
|
||||
defer { registry.release(store) }
|
||||
let lastGood = store.snapshot
|
||||
await settle()
|
||||
|
||||
// The folder is deleted in Finder while the board is open. Bookmark re-resolution finds
|
||||
// nothing that exists, and the last-known path is gone too.
|
||||
try FileManager.default.removeItem(at: fixture.root)
|
||||
|
||||
await waitUntil { store.readOnlyLock == .vanishedRoot }
|
||||
#expect(store.readOnlyLock == .vanishedRoot)
|
||||
|
||||
// Wait for the vanished state to *settle* before recreating anything, and not for tidiness:
|
||||
// entering the lock re-attaches the watcher at the missing path, and that re-attach owes a
|
||||
// debounced reconciling reload. Recreating the folder inside that 200 ms window would let
|
||||
// the reload find the root already back and clear the lock without a root change ever being
|
||||
// delivered — a legitimate recovery, but a different one from the one under test here.
|
||||
// Waiting for that reload to land and fail pins the sequence to the real-world shape: the
|
||||
// root comes back later, and its *creation* is what recovers the board.
|
||||
await waitUntil { store.reloadFailure != nil }
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.reloadFailure != nil, "the re-attach's reconciling reload fails against the missing root")
|
||||
|
||||
// The last-good snapshot is still on screen — that is the whole point of the lock.
|
||||
#expect(store.snapshot == lastGood)
|
||||
#expect(store.readOnlyLock == .vanishedRoot, "a failed reload never lifts the lock")
|
||||
|
||||
// And the strip leads with the lock. (The re-attach's reconciling reload fails against the
|
||||
// missing root, so a breakage row stands behind it; the *lock* is what comes first.)
|
||||
guard case .readOnlyLock(.vanishedRoot) = store.bannerRows.first else {
|
||||
Issue.record("expected the lock row to lead, got \(store.bannerRows.map(\.id))")
|
||||
return
|
||||
}
|
||||
|
||||
// Every write is refused — nothing would land anywhere.
|
||||
do {
|
||||
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
Issue.record("expected the locked board to refuse the write")
|
||||
} catch let refusal as BoardStoreWriteRefusal {
|
||||
#expect(refusal == .readOnlyLocked(.vanishedRoot))
|
||||
}
|
||||
#expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write")
|
||||
|
||||
// The root returns: a Finder undo, a remount, a folder recreated where the board was. Built
|
||||
// aside and moved into place in one step, so the path appears as a whole board rather than
|
||||
// as a directory that is filled in over several reload debounces.
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("RootRecoveryTests-staging-\(UUID().uuidString)", isDirectory: true)
|
||||
try makeBoard(at: staging)
|
||||
try writeCard(inBoard: staging, lane: Ident.lane1, id: Ident.card3, title: "Third", order: "3072")
|
||||
try FileManager.default.moveItem(at: staging, to: fixture.root)
|
||||
|
||||
// FSEvents was left watching the path precisely so this creation would be reported.
|
||||
await waitUntil { store.readOnlyLock == nil }
|
||||
#expect(store.readOnlyLock == nil, "a successful reload proves the root came back")
|
||||
|
||||
await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Third") }
|
||||
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Third"])
|
||||
#expect(store.reloadFailure == nil)
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
|
||||
// The recreated folder is a different file than the one that was deleted, so the entry had
|
||||
// to be re-keyed — otherwise the next window to open this board would get a second store
|
||||
// over a board already on screen.
|
||||
await waitUntil { registry.liveStore(for: fixture.root) === store }
|
||||
#expect(registry.liveStore(for: fixture.root) === store)
|
||||
#expect(registry.openBoardCount == 1)
|
||||
|
||||
// And writes are live again.
|
||||
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: The writability clearing rule
|
||||
|
||||
@Test("The unwritable-location lock clears only on a reconciling reload whose probe passes")
|
||||
func unwritableLockClearsOnlyOnAReconcilingProbe() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
// The probe has to be honest, so the root is made genuinely unwritable — `r-x`, which still
|
||||
// reads perfectly. That is the whole difficulty of this case: the board loads fine.
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
|
||||
store.enterUnwritableLock()
|
||||
#expect(store.readOnlyLock == .unwritableLocation)
|
||||
|
||||
// A foreign reload succeeds — and clears nothing. Loading proves nothing about writing,
|
||||
// which is exactly why this lock's clearing rule is not the other two's.
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.reloadFailure == nil, "an unwritable root still reads")
|
||||
#expect(store.readOnlyLock == .unwritableLocation)
|
||||
|
||||
// Neither does a reconciling one while the permission is still what it was.
|
||||
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.readOnlyLock == .unwritableLocation)
|
||||
|
||||
// The permission is fixed. Nothing announces that — a `chmod` in a terminal fires no event
|
||||
// the board would act on — so the lock stands until the next reconciling sweep (wake, app
|
||||
// activation) re-probes.
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path)
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.readOnlyLock == .unwritableLocation, "only a reconciling reload re-probes")
|
||||
|
||||
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.readOnlyLock == nil, "a fixed permission clears the lock without ceremony")
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A reconciling reload that finds the root unwritable does not raise the lock by itself")
|
||||
func theProbeOnlyClears() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
|
||||
|
||||
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
// Arming the lock is the open flow's job (m4). Inferring it from a probe here would be a
|
||||
// policy decision this layer has not been asked to make — recorded as a test so the
|
||||
// asymmetry is deliberate rather than forgotten.
|
||||
#expect(store.readOnlyLock == nil)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user