Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Semper/Presentation/PresentationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ final class PresentationController {
phase == .preview && !isBusy && (scenePreview?.canApply ?? true)
}

var canCancelOperation: Bool {
isBusy && (phase == .preparing || phase == .starting)
}

var retainedModules: Set<UtilityModuleID> {
guard reservation != nil else { return [] }
var result: Set<UtilityModuleID> = [.scenes]
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -177,14 +181,15 @@ 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()
guard self.now() < deadline else { throw PresentationError.deadlineReached }
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
}
Expand Down
11 changes: 10 additions & 1 deletion Semper/Presentation/PresentationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
}
}
Expand Down
229 changes: 213 additions & 16 deletions SemperTests/PresentationControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -164,6 +169,130 @@ 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 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()
}

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])
#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 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)
#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("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()
Expand Down Expand Up @@ -768,40 +897,43 @@ 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()
}
}

@MainActor
private final class PresentationGate {
private var continuation: CheckedContinuation<Void, Never>?
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() {
Expand All @@ -811,6 +943,71 @@ private final class PresentationGate {
}
}

@MainActor
private final class PresentationSignal {
private var signalled = false
private var waiters: [UUID: CheckedContinuation<Bool, Never>] = [:]

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<Void, Error>] = []

init(gates: [PresentationGate]) {
self.gates = gates
}

func start(_ action: @escaping @MainActor () async throws -> Void) -> Task<Void, Error> {
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)
Expand Down
12 changes: 12 additions & 0 deletions guide/presentation-controls.md
Original file line number Diff line number Diff line change
@@ -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.
Loading