diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index aed367fafaeb..d65bd2b6f195 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -1555,7 +1555,7 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [ ), .testTarget( name: "CmuxNextSidebarTests", - dependencies: ["CmuxNextSidebar"], + dependencies: ["CmuxNextSidebar", "CmuxNextWakeups"], exclude: ["Fixtures"], swiftSettings: uiSwiftSettings ), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift index 5020f1102f54..482eb2922156 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift @@ -129,19 +129,13 @@ final class SidebarRows { } /// Folds or opens `group` at once and keeps that look until `body` - /// replied and the home store holds it, or failed (then `failed`). + /// replied and the home store holds it; a slow reply is sent again + /// quietly, and only a refusal goes to `failed` (`SidebarRowEdits.saveFold`). func fold(_ group: GroupID, collapsed: Bool, on home: DaemonService, failed: @escaping @MainActor (ActionWorkFailure) -> Void, _ body: @escaping DaemonCommandBody) { - let settle = edits.fold(group, collapsed: collapsed) let transaction = ClientTransactionID.generate() - // task-owner: one personal fold command; settles its fold - Task { - if let failure = await home.failure("update-personal-group", body) { - settle() - return failed(failure) - } - home.whenApplied(transaction, settle) - } + edits.saveFold(group, collapsed: collapsed, save: { await home.failure("update-personal-group", body) }, + mayHaveApplied: { $0.mayHaveApplied }, refused: failed) { home.whenApplied(transaction, $0) } } /// Writes `group`'s fold into the live rows kept here (one group, no redraw). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarFoldPersist.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarFoldPersist.swift new file mode 100644 index 000000000000..5354c8571aa2 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarFoldPersist.swift @@ -0,0 +1,25 @@ +public import CmuxNextWakeups + +/// How a group fold's save goes on after its command failed. A fold is this window's look: it shows on the +/// click and never waits for the daemon. A slow save (the reply missed its deadline, so it may still apply) +/// is sent again in the background a few times, with no notice; only a refusal puts the daemon's look back +/// and says why. Leo, 2026-11-03, on "Collapse/Expand Workspace Group did not apply: ... timed out". +public enum SidebarFoldPersist: Equatable, Sendable { + /// Send the same fold again (after the backoff's next delay). + case retry + /// The daemon refused it: drop the fold and report. + case refused + /// Still no reply after every attempt: drop the hold quietly; the store's next echo shows what it holds. + case giveUp + + /// Sends after the first one. + public static let retries = 4 + /// About 0.5, 1, 2 and 4 s between sends. + public static var backoff: Backoff { Backoff(initial: .milliseconds(500), maximum: .seconds(4)) } + + /// What follows failed send `attempt` (0 for the first). `mayHaveApplied` is a missed deadline. + public static func after(attempt: Int, mayHaveApplied: Bool) -> SidebarFoldPersist { + guard mayHaveApplied else { return .refused } + return attempt < retries ? .retry : .giveUp + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRowEdits.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRowEdits.swift index 35e610ba0042..0e9ae696709e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRowEdits.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRowEdits.swift @@ -1,5 +1,6 @@ import CmuxNextCompat import CmuxNextDesign +public import CmuxNextWakeups /// A window's pending row edits and folds, held across live projections until their owner has them. /// The app supplies redraw and confirmed-fold callbacks; this state has no daemon or window dependency. @@ -55,6 +56,43 @@ public final class SidebarRowEdits { } } + /// Whether `group` still shows a pending fold to `collapsed`: a later click (or a settle) replaces it, + /// and then a retried save of this one must not go out. + public func holdsFold(_ group: GroupID, collapsed: Bool) -> Bool { folds[group]?.collapsed == collapsed } + + /// Shows a fold now and saves it in the background: `save` returns nil once the daemon replied, and + /// `saved` then gets the settlement. A failure that only missed its deadline (`mayHaveApplied`) is sent + /// again with no notice (``SidebarFoldPersist``), unless a later click replaced the fold; a refusal + /// drops the fold and goes to `refused`. + public func saveFold(_ group: GroupID, collapsed: Bool, backoff: Backoff = SidebarFoldPersist.backoff, + save: @escaping @MainActor () async -> Failure?, + mayHaveApplied: @escaping @MainActor (Failure) -> Bool, + refused: @escaping @MainActor (Failure) -> Void, + saved: @escaping @MainActor (@escaping @MainActor () -> Void) -> Void) { + let settle = fold(group, collapsed: collapsed) + // task-owner: one fold save and at most SidebarFoldPersist.retries re-sends; ends at a reply, a refusal, + // the last retry, or a later click on the group + Task { [weak self] in + var backoff = backoff + var attempt = 0 + while let failure = await save() { + switch SidebarFoldPersist.after(attempt: attempt, mayHaveApplied: mayHaveApplied(failure)) { + case .refused: + settle() + return refused(failure) + case .giveUp: + return settle() // the daemon may still apply it; its echo shows what it holds + case .retry: + // concurrency-allow: Backoff.wait is an async clock sleep (it suspends this task, never blocks the main thread) + do { try await backoff.wait(owner: "sidebar.foldSave") } catch { return settle() } + guard let self, holdsFold(group, collapsed: collapsed) else { return } + attempt += 1 + } + } + saved(settle) + } + } + /// Shows a document fold until its echo reaches the live rows, or a refusal drops it. public func foldShown(_ group: GroupID, collapsed: Bool) { foldSerial += 1 diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarFoldPersistTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarFoldPersistTests.swift new file mode 100644 index 000000000000..d2cdeb83837d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarFoldPersistTests.swift @@ -0,0 +1,89 @@ +@testable import CmuxNextSidebar +import CmuxNextWakeups +import Testing + +/// Collapsing a group is this window's look: a save whose reply is slow is sent again quietly, and only a +/// refusal undoes the fold with a notice (Leo, 2026-11-03: "Collapse/Expand Workspace Group did not apply: +/// update-personal-group: timed out: workspace_group.update (no reply within 2.0 seconds)"). +@MainActor @Suite struct SidebarFoldPersistTests { + private struct Failure: Sendable { var timedOut: Bool } + private let group = GroupID("grp_fold") + private let quick = Backoff(initial: .milliseconds(1), maximum: .milliseconds(1), jitter: 0) + + private func edits() -> (SidebarRowEdits, SidebarModel) { + let model = SidebarModel() + return (SidebarRowEdits(model: model, redraw: {}, confirmedFold: { _, _ in }), model) + } + + /// What one fold save did: the sends that went out, the refusals reported, and whether it settled through `saved`. + @MainActor private final class Record { + var sends = 0, refused = 0, saved = false + var done: CheckedContinuation? + func finish() { done?.resume(); done = nil } + } + + /// Saves one fold with `answers` as the daemon's replies in order (nil: replied). `before` runs at the first send. + private func run(_ edits: SidebarRowEdits, _ answers: [Failure?], before: (@MainActor () -> Void)? = nil) + async -> (sends: Int, refused: Int, saved: Bool) { + let record = Record() + let group = group, quick = quick + await withCheckedContinuation { (done: CheckedContinuation) in + record.done = done + edits.saveFold(group, collapsed: true, backoff: quick, save: { + let index = record.sends + record.sends += 1 + if index == 0 { before?() } + let answer = index < answers.count ? answers[index] : nil + // The last scripted failure (or the one a later click supersedes) ends the run. + if answer != nil, index == answers.count - 1 || before != nil { record.finish() } + return answer + }, mayHaveApplied: { $0.timedOut }, refused: { _ in record.refused += 1; record.finish() }, + saved: { settle in record.saved = true; settle(); record.finish() }) + } + // Let a retry that should not happen have its chance. + try? await Task.sleep(for: .milliseconds(50)) + return (record.sends, record.refused, record.saved) + } + + @Test func aSlowReplyIsSentAgainWithNoNoticeAndTheFoldStays() async { + let (edits, model) = edits() + let result = await run(edits, [Failure(timedOut: true), Failure(timedOut: true), nil]) + #expect(result.sends == 3) + #expect(result.refused == 0, "a slow save must not report a failure") + #expect(result.saved) + _ = model + } + + @Test func aRefusalIsReportedOnceWithNoRetry() async { + let (edits, _) = edits() + let result = await run(edits, [Failure(timedOut: false)]) + #expect(result.sends == 1) + #expect(result.refused == 1) + #expect(!edits.holdsFold(group, collapsed: true), "a refusal drops the fold") + } + + @Test func aSaveThatNeverAnswersGivesUpQuietly() async { + let (edits, _) = edits() + let timeouts = Array(repeating: Failure?.some(Failure(timedOut: true)), count: SidebarFoldPersist.retries + 1) + let result = await run(edits, timeouts) + #expect(result.sends == SidebarFoldPersist.retries + 1) + #expect(result.refused == 0) + #expect(!result.saved) + } + + @Test func aLaterClickStopsTheEarlierFoldsRetries() async { + let (edits, _) = edits() + let result = await run(edits, [Failure(timedOut: true)]) { + _ = edits.fold(group, collapsed: false) + } + #expect(result.sends == 1, "the stale fold must not be sent again over the newer click") + #expect(result.refused == 0) + #expect(edits.holdsFold(group, collapsed: false)) + } + + @Test func policy() { + #expect(SidebarFoldPersist.after(attempt: 0, mayHaveApplied: false) == .refused) + #expect(SidebarFoldPersist.after(attempt: 0, mayHaveApplied: true) == .retry) + #expect(SidebarFoldPersist.after(attempt: SidebarFoldPersist.retries, mayHaveApplied: true) == .giveUp) + } +}