From 8459b57b0262d307eb242ceff923abb674e56719 Mon Sep 17 00:00:00 2001 From: Nihar <117209695+niharnm@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:50:52 -0700 Subject: [PATCH 1/2] Keep Presentation cancellation available during startup --- .../Presentation/PresentationController.swift | 9 ++- Semper/Presentation/PresentationView.swift | 11 ++- SemperTests/PresentationControllerTests.swift | 77 +++++++++++++++++++ guide/presentation-controls.md | 12 +++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 guide/presentation-controls.md diff --git a/Semper/Presentation/PresentationController.swift b/Semper/Presentation/PresentationController.swift index 516667e..4c481d2 100644 --- a/Semper/Presentation/PresentationController.swift +++ b/Semper/Presentation/PresentationController.swift @@ -102,6 +102,10 @@ final class PresentationController { phase == .preview && !isBusy && (scenePreview?.canApply ?? true) } + var canCancelOperation: Bool { + isBusy && (phase == .preparing || phase == .starting) + } + var retainedModules: Set { guard reservation != nil else { return [] } var result: Set = [.scenes] @@ -143,7 +147,7 @@ final class PresentationController { try Task.checkCancellation() self.phase = .preview } catch { - self.message = error.localizedDescription + self.message = error is CancellationError ? "Presentation cancelled." : error.localizedDescription await self.recoverAfterFailure() throw error } @@ -177,6 +181,7 @@ final class PresentationController { if let plan = draft.workspacePlan, let workspace = self.workspace { let result = await workspace.apply(plan, ownerToken: token) self.workspaceReceipt = result + try Task.checkCancellation() guard result.outcome == .completed else { throw PresentationError.workspacePartial } } try Task.checkCancellation() @@ -184,7 +189,7 @@ final class PresentationController { self.phase = .active self.message = "Presentation is active. Later manual changes will be preserved during restore." } catch { - self.message = error.localizedDescription + self.message = error is CancellationError ? "Presentation cancelled." : error.localizedDescription await self.recoverAfterFailure() throw error } diff --git a/Semper/Presentation/PresentationView.swift b/Semper/Presentation/PresentationView.swift index f52c04f..30dd2e0 100644 --- a/Semper/Presentation/PresentationView.swift +++ b/Semper/Presentation/PresentationView.swift @@ -28,7 +28,15 @@ struct PresentationView: View { .textSelection(.enabled) } if controller.isBusy { - HStack { ProgressView().controlSize(.small); Text("Finishing the current operation…") } + HStack { + ProgressView().controlSize(.small) + Text("Finishing the current operation…") + if controller.canCancelOperation { + Button("Cancel Presentation") { run { try await controller.stop() } } + .keyboardShortcut(.cancelAction) + .help("Cancel preparation or startup and restore any applied changes.") + } + } } if controller.reservation == nil { if runtime.scenes?.hasPendingRestore == true { @@ -304,6 +312,7 @@ struct PresentationView: View { private func run(_ action: @escaping @MainActor () async throws -> Void) { Task { @MainActor in do { errorMessage = nil; try await action() } + catch is CancellationError { errorMessage = nil } catch { errorMessage = error.localizedDescription } } } diff --git a/SemperTests/PresentationControllerTests.swift b/SemperTests/PresentationControllerTests.swift index 69c340a..077e143 100644 --- a/SemperTests/PresentationControllerTests.swift +++ b/SemperTests/PresentationControllerTests.swift @@ -16,6 +16,7 @@ struct PresentationControllerTests { #expect(fixture.controller.phase == .preview) #expect(fixture.controller.canStart) + #expect(!fixture.controller.canCancelOperation) #expect(fixture.trace.events == [.sceneReserve, .workspaceReserve, .scenePreview]) #expect(fixture.backend.activeIDs.isEmpty) #expect(fixture.controller.deadline == nil) @@ -32,12 +33,15 @@ struct PresentationControllerTests { fixture.reserveGate = gate let prepare = Task { try await fixture.prepare() } #expect(await gate.waitUntilSuspended()) + #expect(fixture.controller.canCancelOperation) prepare.cancel() gate.resume() await #expect(throws: CancellationError.self) { try await prepare.value } #expect(fixture.controller.phase == .idle) + #expect(!fixture.controller.canCancelOperation) + #expect(fixture.controller.message == "Presentation cancelled.") #expect(fixture.controller.reservation == nil) #expect(fixture.trace.events == [.sceneReserve, .scenePending, .sceneRelease]) #expect(fixture.backend.activeIDs.isEmpty) @@ -69,6 +73,7 @@ struct PresentationControllerTests { try await fixture.controller.start() #expect(fixture.controller.phase == .active) + #expect(!fixture.controller.canCancelOperation) #expect(fixture.receivedPreview == preview) #expect(fixture.trace.events == [.awakeAcquire, .sceneApply, .workspaceApply]) #expect(fixture.backend.timeouts == [1800, 1800]) @@ -164,6 +169,78 @@ struct PresentationControllerTests { #expect(fixture.backend.activeIDs.isEmpty) } + @Test("The visible cancel action stops startup and waits for window recovery", arguments: [false, true]) + func cancelActionDrainsStartupRecovery(cancelledReceipt: Bool) async throws { + let fixture = PresentationFixture() + defer { fixture.awake.shutdown() } + let applyGate = PresentationGate() + let reverseGate = PresentationGate() + fixture.workspace.applyGate = applyGate + fixture.workspace.reverseGate = reverseGate + if cancelledReceipt { + fixture.workspace.appliedReceipt = fixture.workspace.makeReceipt( + outcome: .cancelled, recovery: fixture.workspace.pendingRecovery) + } + try await fixture.prepare() + let start = Task { try await fixture.controller.start() } + #expect(await applyGate.waitUntilSuspended()) + #expect(fixture.controller.canCancelOperation) + var stopFinished = false + let stop = Task { + defer { stopFinished = true } + try await fixture.controller.stop() + } + + #expect(await fixture.trace.waitFor(.workspaceApplyCancelled)) + #expect(!stopFinished) + applyGate.resume() + #expect(await reverseGate.waitUntilSuspended()) + #expect(fixture.controller.phase == .restoring) + #expect(!fixture.controller.canCancelOperation) + #expect(!stopFinished) + reverseGate.resume() + await #expect(throws: CancellationError.self) { try await start.value } + try await stop.value + + #expect(fixture.workspace.reversedIDs == [fixture.workspace.appliedReceipt.operationID]) + #expect(fixture.workspace.reverseWasCancelled == [false]) + #expect(fixture.restoreWasCancelled == [false]) + #expect(fixture.controller.phase == .idle) + #expect(!fixture.controller.canCancelOperation) + #expect(fixture.controller.reservation == nil) + #expect(fixture.backend.activeIDs.isEmpty) + #expect(fixture.controller.message == "Presentation cancelled.") + } + + @Test("A cancelled window receipt keeps failed recovery visible and owned") + func cancelledReceiptRetainsFailedRecovery() async throws { + let fixture = PresentationFixture() + defer { fixture.awake.shutdown() } + let gate = PresentationGate() + fixture.workspace.applyGate = gate + fixture.workspace.appliedReceipt = fixture.workspace.makeReceipt( + outcome: .cancelled, recovery: fixture.workspace.pendingRecovery) + fixture.workspace.reverseResults = (0..<2).map { _ in + fixture.workspace.makeReceipt(outcome: .partial, recovery: fixture.workspace.pendingRecovery) + } + try await fixture.prepare() + let start = Task { try await fixture.controller.start() } + #expect(await gate.waitUntilSuspended()) + let stop = Task { try await fixture.controller.stop() } + #expect(await fixture.trace.waitFor(.workspaceApplyCancelled)) + gate.resume() + await #expect(throws: CancellationError.self) { try await start.value } + await #expect(throws: PresentationError.self) { try await stop.value } + + #expect(fixture.controller.phase == .recoveryRequired) + #expect(!fixture.controller.canCancelOperation) + #expect(fixture.controller.reservation == fixture.token) + #expect(fixture.controller.workspaceReceipt?.needsRecovery == true) + #expect(fixture.controller.message?.contains("Some windows could not be restored.") == true) + #expect(fixture.workspace.reverseWasCancelled == [false, false]) + try await fixture.controller.stop() + } + @Test("Expiry during a suspended start cancels it and waits for recovery") func expiryDuringStart() async throws { let fixture = PresentationFixture() diff --git a/guide/presentation-controls.md b/guide/presentation-controls.md new file mode 100644 index 0000000..fe0c491 --- /dev/null +++ b/guide/presentation-controls.md @@ -0,0 +1,12 @@ +# Presentation controls + +Add Presentation and Awake in Modules, then open Presentation from Home or the sidebar. Optional display, Sound, and window changes require their respective modules to be added and running. + +1. Choose a duration and the settings to apply. +2. Select Preview Presentation and review the requested changes. +3. Select Start Presentation to apply them and begin the timer. +4. Select End and Restore when finished. Later manual changes stay in place. + +During preview preparation or startup, Cancel Presentation remains available beside the progress indicator. Escape invokes the same action while this control is present. Cancellation waits for pending work and restores any changes already applied. Recovery itself cannot be cancelled. If recovery fails, review the displayed results and use Retry Cleanup. Keep Current Setup requires confirmation and accepts the current state instead of restoring it. + +Edit Selection returns a completed preview to configuration. Normal active sessions retain End and Restore and Keep Current Setup. Window recovery lasts only for the current Semper session, so finish it before quitting. From 629ef7cc3d0bd58d88c62e0238c10d3e78413cf4 Mon Sep 17 00:00:00 2001 From: Nihar <117209695+niharnm@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:05:19 -0700 Subject: [PATCH 2/2] Synchronize Presentation cancellation test boundaries --- SemperTests/PresentationControllerTests.swift | 202 ++++++++++++++---- 1 file changed, 161 insertions(+), 41 deletions(-) diff --git a/SemperTests/PresentationControllerTests.swift b/SemperTests/PresentationControllerTests.swift index 077e143..847ce29 100644 --- a/SemperTests/PresentationControllerTests.swift +++ b/SemperTests/PresentationControllerTests.swift @@ -4,7 +4,7 @@ import IOKit.pwr_mgt import Testing @testable import Semper -@Suite("Presentation controller") +@Suite("Presentation controller", .timeLimit(.minutes(1))) @MainActor struct PresentationControllerTests { @Test("Preparing a preview reserves ownership without writes or Awake assertions") @@ -182,25 +182,28 @@ struct PresentationControllerTests { outcome: .cancelled, recovery: fixture.workspace.pendingRecovery) } try await fixture.prepare() - let start = Task { try await fixture.controller.start() } - #expect(await applyGate.waitUntilSuspended()) - #expect(fixture.controller.canCancelOperation) - var stopFinished = false - let stop = Task { - defer { stopFinished = true } - try await fixture.controller.stop() - } + let work = PresentationTestWork(gates: [applyGate, reverseGate]) + try await work.run { + let start = work.start { try await fixture.controller.start() } + try #require(await applyGate.waitUntilSuspended()) + #expect(fixture.controller.canCancelOperation) + var stopFinished = false + let stop = work.start { + defer { stopFinished = true } + try await fixture.controller.stop() + } - #expect(await fixture.trace.waitFor(.workspaceApplyCancelled)) - #expect(!stopFinished) - applyGate.resume() - #expect(await reverseGate.waitUntilSuspended()) - #expect(fixture.controller.phase == .restoring) - #expect(!fixture.controller.canCancelOperation) - #expect(!stopFinished) - reverseGate.resume() - await #expect(throws: CancellationError.self) { try await start.value } - try await stop.value + try #require(await fixture.trace.waitFor(.workspaceApplyCancelled)) + #expect(!stopFinished) + applyGate.resume() + try #require(await reverseGate.waitUntilSuspended()) + #expect(fixture.controller.phase == .restoring) + #expect(!fixture.controller.canCancelOperation) + #expect(!stopFinished) + reverseGate.resume() + await #expect(throws: CancellationError.self) { try await start.value } + try await stop.value + } #expect(fixture.workspace.reversedIDs == [fixture.workspace.appliedReceipt.operationID]) #expect(fixture.workspace.reverseWasCancelled == [false]) @@ -224,13 +227,16 @@ struct PresentationControllerTests { fixture.workspace.makeReceipt(outcome: .partial, recovery: fixture.workspace.pendingRecovery) } try await fixture.prepare() - let start = Task { try await fixture.controller.start() } - #expect(await gate.waitUntilSuspended()) - let stop = Task { try await fixture.controller.stop() } - #expect(await fixture.trace.waitFor(.workspaceApplyCancelled)) - gate.resume() - await #expect(throws: CancellationError.self) { try await start.value } - await #expect(throws: PresentationError.self) { try await stop.value } + let work = PresentationTestWork(gates: [gate]) + try await work.run { + let start = work.start { try await fixture.controller.start() } + try #require(await gate.waitUntilSuspended()) + let stop = work.start { try await fixture.controller.stop() } + try #require(await fixture.trace.waitFor(.workspaceApplyCancelled)) + gate.resume() + await #expect(throws: CancellationError.self) { try await start.value } + await #expect(throws: PresentationError.self) { try await stop.value } + } #expect(fixture.controller.phase == .recoveryRequired) #expect(!fixture.controller.canCancelOperation) @@ -241,6 +247,52 @@ struct PresentationControllerTests { try await fixture.controller.stop() } + @Test("Cancelling an arrival observer preserves delayed Workspace entry", arguments: [false, true]) + func cancelledObserverBeforeDelayedWorkspaceArrival(observeTrace: Bool) async throws { + let fixture = PresentationFixture() + defer { fixture.awake.shutdown() } + let sceneGate = PresentationGate() + let workspaceGate = PresentationGate() + fixture.sceneApplyGate = sceneGate + fixture.workspace.applyGate = workspaceGate + try await fixture.prepare() + let work = PresentationTestWork(gates: [sceneGate, workspaceGate]) + try await work.run { + let start = work.start { try await fixture.controller.start() } + try #require(await sceneGate.waitUntilSuspended()) + #expect(!fixture.trace.events.contains(.workspaceApply)) + + let observing = PresentationSignal() + let observer = work.start { + observing.signal() + let arrived: Bool + if observeTrace { + arrived = await fixture.trace.waitFor(.workspaceApply) + } else { + arrived = await workspaceGate.waitUntilSuspended() + } + #expect(!arrived) + } + try #require(await observing.wait()) + observer.cancel() + try await observer.value + #expect(!fixture.trace.events.contains(.workspaceApply)) + + sceneGate.resume() + try #require(await workspaceGate.waitUntilSuspended()) + try #require(await fixture.trace.waitFor(.workspaceApply)) + #expect(fixture.controller.canCancelOperation) + start.cancel() + try #require(await fixture.trace.waitFor(.workspaceApplyCancelled)) + workspaceGate.resume() + await #expect(throws: CancellationError.self) { try await start.value } + } + #expect(fixture.workspace.reversedIDs == [fixture.workspace.appliedReceipt.operationID]) + #expect(fixture.controller.phase == .idle) + #expect(fixture.controller.reservation == nil) + #expect(fixture.backend.activeIDs.isEmpty) + } + @Test("Expiry during a suspended start cancels it and waits for recovery") func expiryDuringStart() async throws { let fixture = PresentationFixture() @@ -845,20 +897,24 @@ private final class PresentationWorkspaceFake: PresentationWorkspaceHandling { @MainActor private final class PresentationTrace { - enum Event: Equatable { + enum Event: Hashable { case sceneReserve, scenePreview, sceneApply, scenePending, sceneRestore, sceneKeep, sceneRelease case workspaceReserve, workspaceApply, workspaceReverse, workspaceRelease, workspaceKeep case awakeAcquire, awakeRelease, sceneApplyCancelled, workspaceApplyCancelled } - var events: [Event] = [] + var events: [Event] = [] { + didSet { + for event in events { arrivals.removeValue(forKey: event)?.signal() } + } + } + private var arrivals: [Event: PresentationSignal] = [:] func waitFor(_ event: Event) async -> Bool { - for _ in 0..<100 { - if events.contains(event) { return true } - do { try await Task.sleep(for: .milliseconds(10)) } - catch { return false } - } - return events.contains(event) + guard !Task.isCancelled else { return false } + if events.contains(event) { return true } + let arrival = arrivals[event] ?? PresentationSignal() + arrivals[event] = arrival + return await arrival.wait() } } @@ -866,19 +922,18 @@ private final class PresentationTrace { private final class PresentationGate { private var continuation: CheckedContinuation? private var resumed = false + private let arrival = PresentationSignal() func suspend() async { if resumed { return } - await withCheckedContinuation { continuation = $0 } + await withCheckedContinuation { + continuation = $0 + arrival.signal() + } } func waitUntilSuspended() async -> Bool { - for _ in 0..<100 { - if continuation != nil { return true } - do { try await Task.sleep(for: .milliseconds(10)) } - catch { return false } - } - return continuation != nil + await arrival.wait() } func resume() { @@ -888,6 +943,71 @@ private final class PresentationGate { } } +@MainActor +private final class PresentationSignal { + private var signalled = false + private var waiters: [UUID: CheckedContinuation] = [:] + + func signal() { + signalled = true + let pending = Array(waiters.values) + waiters.removeAll() + for waiter in pending { waiter.resume(returning: true) } + } + + func wait() async -> Bool { + guard !Task.isCancelled else { return false } + if signalled { return true } + let id = UUID() + let arrived = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if Task.isCancelled { + continuation.resume(returning: false) + } else if signalled { + continuation.resume(returning: true) + } else { + waiters[id] = continuation + } + } + } onCancel: { + Task { @MainActor in self.waiters.removeValue(forKey: id)?.resume(returning: false) } + } + return arrived && !Task.isCancelled + } +} + +@MainActor +private final class PresentationTestWork { + private let gates: [PresentationGate] + private var tasks: [Task] = [] + + init(gates: [PresentationGate]) { + self.gates = gates + } + + func start(_ action: @escaping @MainActor () async throws -> Void) -> Task { + let task = Task { try await action() } + tasks.append(task) + return task + } + + func run(_ body: @MainActor () async throws -> Void) async throws { + do { + try await body() + } catch { + await drain() + throw error + } + await drain() + } + + private func drain() async { + for task in tasks { task.cancel() } + for gate in gates { gate.resume() } + for task in tasks { _ = await task.result } + } +} + @MainActor private final class PresentationClock { var current = Date(timeIntervalSince1970: 1_700_000_000)