From f8211303ea6e56c8cdc8579bd5e1a2683c93aef2 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 00:32:06 +0200 Subject: [PATCH 1/8] Refactor: Drive app runtime from async events - Replace MainActor polling with a serialized async event channel. - Deliver terminal input and POSIX signals through Dispatch sources. - Cover task resumption and macOS/Linux event delivery. --- Sources/TUIkit/App/App.swift | 126 +++++------ Sources/TUIkit/App/RuntimeEventSource.swift | 109 ++++++++++ Sources/TUIkit/App/SignalManager.swift | 204 +++++++++--------- .../{main.swift => ExampleApp.swift} | 8 +- Tests/TUIkitTests/AppRunnerTests.swift | 61 ++++++ .../TUIkitTests/RuntimeEventSourceTests.swift | 81 +++++++ 6 files changed, 426 insertions(+), 163 deletions(-) create mode 100644 Sources/TUIkit/App/RuntimeEventSource.swift rename Sources/TUIkitExample/{main.swift => ExampleApp.swift} (78%) create mode 100644 Tests/TUIkitTests/AppRunnerTests.swift create mode 100644 Tests/TUIkitTests/RuntimeEventSourceTests.swift diff --git a/Sources/TUIkit/App/App.swift b/Sources/TUIkit/App/App.swift index 502f820bc..aacfcb585 100644 --- a/Sources/TUIkit/App/App.swift +++ b/Sources/TUIkit/App/App.swift @@ -50,15 +50,10 @@ extension App { /// This method is called by the `@main` attribute and starts /// the main run loop of the application. /// - /// Since TUIKit runs on the main thread and `@main` entry points - /// execute on the main thread, we use `MainActor.assumeIsolated` - /// to access MainActor-isolated types synchronously. - public static func main() { - MainActor.assumeIsolated { - let app = Self() - let runner = AppRunner(app: app) - runner.run() - } + public static func main() async { + let app = Self() + let runner = AppRunner(app: app) + await runner.run() } } @@ -82,13 +77,17 @@ internal final class AppRunner { private let statusBar: StatusBarState private let terminal: any TerminalProtocol private let tuiContext: TUIContext - private var isRunning = false - private var signals = SignalManager() + private let eventChannel: RuntimeEventChannel + private let inputSource: TerminalInputSource? + private let signals: SignalManager? init( app: A, terminal: (any TerminalProtocol)? = nil, - tuiContext: TUIContext? = nil + tuiContext: TUIContext? = nil, + eventChannel: RuntimeEventChannel = RuntimeEventChannel(), + inputSource: TerminalInputSource? = TerminalInputSource(), + signals: SignalManager? = SignalManager() ) { let tuiContext = tuiContext ?? TUIContext.production() self.app = app @@ -101,13 +100,16 @@ internal final class AppRunner { self.statusBar.style = .bordered self.terminal = terminal ?? Terminal() self.tuiContext = tuiContext + self.eventChannel = eventChannel + self.inputSource = inputSource + self.signals = signals } } // MARK: - Internal API extension AppRunner { - func run() { + func run() async { // Create run-loop dependencies (previously IUOs, now local variables) let inputHandler = InputHandler( statusBar: statusBar, @@ -115,8 +117,8 @@ extension AppRunner { focusManager: focusManager, paletteManager: paletteManager, appearanceManager: appearanceManager, - onQuit: { [weak self] in - self?.isRunning = false + onQuit: { [eventChannel] in + eventChannel.send(.shutdownRequested) } ) let renderer = RenderLoop( @@ -133,14 +135,17 @@ extension AppRunner { let cursorTimer = CursorTimer(renderNotifier: appState) // Setup - signals.install() + if let signals { + await signals.install(sendingTo: eventChannel) + } + inputSource?.start(sendingTo: eventChannel) terminal.enterAlternateScreen() terminal.hideCursor() terminal.enableRawMode() // Register for state changes - appState.observe { [signals] in - signals.requestRerender() + appState.observe { [eventChannel] in + eventChannel.send(.renderRequested) } // Reset pulse animation and trigger re-render when focus changes @@ -150,64 +155,65 @@ extension AppRunner { runtimeFocusChangeHandler?() } - isRunning = true - // Start animation timers pulseTimer.start() cursorTimer.start() // Initial render + appState.didRender() renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) - // Main loop - while isRunning { - // Check for graceful shutdown request (from SIGINT handler) - if signals.shouldShutdown { - isRunning = false - break - } - - // Invalidate diff cache on terminal resize so every line - // is rewritten with the new dimensions. - if signals.consumeResizeFlag() { - renderer.invalidateDiffCache() - } - - // Check if terminal was resized or state changed - if signals.consumeRerenderFlag() || appState.needsRender { - appState.didRender() - renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) - } + defer { + pulseTimer.stop() + cursorTimer.stop() + inputSource?.stop() + signals?.stop() + eventChannel.finish() + cleanup() + } - // Read key events (non-blocking with VTIME=0) - // Process all available events per frame. A high limit prevents - // input buffering lag during paste operations while still avoiding - // infinite loops if input arrives faster than we can process. - var eventsProcessed = 0 - let maxEventsPerFrame = 128 - while eventsProcessed < maxEventsPerFrame, - let keyEvent = terminal.readKeyEvent() { - inputHandler.handle(keyEvent) - eventsProcessed += 1 + await withTaskCancellationHandler { + var iterator = eventChannel.events.makeAsyncIterator() + while let event = await iterator.next() { + switch event { + case .renderRequested: + guard appState.needsRender else { continue } + appState.didRender() + renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) + + case .inputAvailable: + processAvailableInput(using: inputHandler) + + case .terminalResized: + renderer.invalidateDiffCache() + appState.didRender() + renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) + + case .shutdownRequested: + return + } } - - // Sleep ~24ms to yield CPU. - // This sets the maximum frame rate to ~42 FPS. - // - usleep(23_800) + } onCancel: { [eventChannel] in + eventChannel.send(.shutdownRequested) } - - // Stop pulse timer before cleanup - pulseTimer.stop() - - // Cleanup - cleanup() } } // MARK: - Private Helpers private extension AppRunner { + /// Drains a bounded batch after the input descriptor becomes readable. + func processAvailableInput(using inputHandler: InputHandler) { + var eventsProcessed = 0 + let maxEventsPerBatch = 128 + + while eventsProcessed < maxEventsPerBatch, + let keyEvent = terminal.readKeyEvent() { + inputHandler.handle(keyEvent) + eventsProcessed += 1 + } + } + func cleanup() { terminal.disableRawMode() terminal.showCursor() diff --git a/Sources/TUIkit/App/RuntimeEventSource.swift b/Sources/TUIkit/App/RuntimeEventSource.swift new file mode 100644 index 000000000..9c0ba5292 --- /dev/null +++ b/Sources/TUIkit/App/RuntimeEventSource.swift @@ -0,0 +1,109 @@ +// 🖥️ TUIkit — Terminal UI Kit for Swift +// RuntimeEventSource.swift +// +// License: MIT + +import Dispatch + +#if canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#elseif canImport(Darwin) + import Darwin +#endif + +// MARK: - Runtime Event + +/// Work serialized by the owning application runtime. +internal enum RuntimeEvent: Sendable { + /// Application state requested another render pass. + case renderRequested + + /// Standard input has bytes ready to consume. + case inputAvailable + + /// The terminal dimensions changed. + case terminalResized + + /// The application should shut down gracefully. + case shutdownRequested +} + +// MARK: - Runtime Event Channel + +/// Thread-safe bridge from system callbacks to the async application loop. +internal final class RuntimeEventChannel: Sendable { + /// Events consumed by exactly one application runtime. + let events: AsyncStream + + /// Thread-safe continuation used by signal, input, and state callbacks. + private let continuation: AsyncStream.Continuation + + /// Creates an unbounded channel so shutdown and resize events are never dropped. + init() { + let pair = AsyncStream.makeStream( + of: RuntimeEvent.self, + bufferingPolicy: .unbounded + ) + self.events = pair.stream + self.continuation = pair.continuation + } + + /// Enqueues work for the application runtime. + func send(_ event: RuntimeEvent) { + continuation.yield(event) + } + + /// Creates a nonisolated callback suitable for Dispatch event handlers. + func sender(for event: RuntimeEvent) -> @Sendable () -> Void { + { [self] in + send(event) + } + } + + /// Finishes the event stream and resumes a suspended consumer. + func finish() { + continuation.finish() + } +} + +// MARK: - Terminal Input Source + +/// Converts terminal file-descriptor readiness into runtime events. +@MainActor +internal final class TerminalInputSource { + /// Standard input descriptor observed by the source. + private let fileDescriptor: Int32 + + /// Active dispatch source, if input observation has started. + private var source: DispatchSourceRead? + + /// Creates an input source for the supplied descriptor. + init(fileDescriptor: Int32 = STDIN_FILENO) { + self.fileDescriptor = fileDescriptor + } +} + +// MARK: - Internal API + +extension TerminalInputSource { + /// Starts observing input readiness. + func start(sendingTo channel: RuntimeEventChannel) { + guard source == nil else { return } + + let source = DispatchSource.makeReadSource( + fileDescriptor: fileDescriptor, + queue: DispatchQueue.global(qos: .userInteractive) + ) + source.setEventHandler(handler: channel.sender(for: .inputAvailable)) + source.resume() + self.source = source + } + + /// Stops observing without closing the terminal-owned descriptor. + func stop() { + source?.cancel() + source = nil + } +} diff --git a/Sources/TUIkit/App/SignalManager.swift b/Sources/TUIkit/App/SignalManager.swift index 6cf5cc909..ed648827b 100644 --- a/Sources/TUIkit/App/SignalManager.swift +++ b/Sources/TUIkit/App/SignalManager.swift @@ -12,121 +12,129 @@ import Darwin #endif -// MARK: - Signal Flags +import Dispatch -/// Signal flags use `nonisolated(unsafe)` intentionally. -/// -/// ## Why not use locks or atomics? -/// -/// POSIX signal handlers have strict constraints: they may only call -/// "async-signal-safe" functions. Lock acquisition (pthread_mutex_lock, -/// os_unfair_lock_lock) and most Swift runtime functions are NOT safe. -/// -/// Bool read/write on modern CPUs (arm64, x86_64) is effectively atomic -/// for single-word aligned values. The worst case is a torn read, which -/// for a Bool just means we might miss one signal or see it twice. Both -/// are acceptable: re-rendering twice is harmless, and a missed signal -/// will be caught on the next iteration. -/// -/// ## Swift 6 Concurrency +// MARK: - Signal Manager + +/// Manages POSIX signal handlers for the application lifecycle. /// -/// `nonisolated(unsafe)` is the correct annotation here. These flags are -/// genuinely unsafe in the general case, but safe in our specific usage -/// pattern (single writer from signal handler, single reader from main loop). +/// Dispatch owns the low-level signal bridge, so no Swift state is read or +/// mutated from a POSIX signal handler. The application runtime receives +/// ordinary async events on its event channel. +@MainActor +internal final class SignalManager { + /// C-compatible signal disposition returned by `signal()`. + private typealias SignalDisposition = @convention(c) (Int32) -> Void -/// Flag set by the SIGWINCH signal handler to request a re-render. -nonisolated(unsafe) private var signalNeedsRerender = false + /// One installed dispatch source and the disposition it replaced. + private struct Registration { + let number: Int32 + let previousDisposition: SignalDisposition? + let source: DispatchSourceSignal + } -/// Flag set by the SIGWINCH signal handler to indicate a terminal resize. -/// -/// Separate from `signalNeedsRerender` because resize requires additional -/// work (invalidating the frame diff cache) beyond just re-rendering. -nonisolated(unsafe) private var signalTerminalResized = false + /// Active registrations owned by this application runtime. + private var registrations: [Registration] = [] -/// Flag set by the SIGINT signal handler to request a graceful shutdown. -/// -/// The actual cleanup (disabling raw mode, restoring cursor, exiting -/// alternate screen) happens in the main loop. Signal handlers must -/// not call non-async-signal-safe functions like `write()` or `fflush()`. -nonisolated(unsafe) private var signalNeedsShutdown = false + /// Creates an uninstalled signal manager. + init() {} +} -// MARK: - Signal Manager +/// One-shot barrier for Dispatch source registration callbacks. +private final class SignalRegistrationBarrier: Sendable { + /// Registration notifications consumed by the installer. + let events: AsyncStream -/// Manages POSIX signal handlers for the application lifecycle. -/// -/// Encapsulates the global signal flags and handler installation. -/// The flags remain file-private globals because C signal handlers -/// cannot capture Swift object references. -/// -/// ## Usage -/// -/// ```swift -/// let signals = SignalManager() -/// signals.install() -/// -/// while running { -/// if signals.shouldShutdown { break } -/// if signals.consumeRerenderFlag() { render() } -/// } -/// ``` -internal struct SignalManager { - /// Whether a graceful shutdown was requested (SIGINT). - var shouldShutdown: Bool { - signalNeedsShutdown + /// Thread-safe producer used by Dispatch callbacks. + private let continuation: AsyncStream.Continuation + + /// Creates a barrier that preserves callbacks arriving before the wait. + init() { + let pair = AsyncStream.makeStream( + of: Void.self, + bufferingPolicy: .unbounded + ) + self.events = pair.stream + self.continuation = pair.continuation + } + + /// Creates a nonisolated Dispatch callback. + func callback() -> @Sendable () -> Void { + { [self] in + continuation.yield() + } + } + + /// Finishes the registration stream. + func finish() { + continuation.finish() } } // MARK: - Internal API extension SignalManager { - /// Checks and resets the rerender flag (SIGWINCH or state change). - /// - /// Returns `true` if a re-render was requested since the last call, - /// then resets the flag. This consume-on-read pattern prevents - /// redundant renders. - /// - /// - Returns: `true` if a rerender was requested. - mutating func consumeRerenderFlag() -> Bool { - guard signalNeedsRerender else { return false } - signalNeedsRerender = false - return true - } + /// Installs dispatch-backed sources for resize and termination signals. + func install(sendingTo channel: RuntimeEventChannel) async { + guard registrations.isEmpty else { return } - /// Checks and resets the terminal resize flag (SIGWINCH). - /// - /// Returns `true` if the terminal was resized since the last call, - /// then resets the flag. Used by `AppRunner` to invalidate the - /// frame diff cache on resize. - /// - /// - Returns: `true` if a terminal resize occurred. - mutating func consumeResizeFlag() -> Bool { - guard signalTerminalResized else { return false } - signalTerminalResized = false - return true - } + let barrier = SignalRegistrationBarrier() + register(SIGWINCH, event: .terminalResized, sendingTo: channel, barrier: barrier) + register(SIGINT, event: .shutdownRequested, sendingTo: channel, barrier: barrier) + register(SIGTERM, event: .shutdownRequested, sendingTo: channel, barrier: barrier) - /// Requests a re-render programmatically. - /// - /// Called by the `AppState` observer to signal that application - /// state has changed and the UI needs updating. - func requestRerender() { - signalNeedsRerender = true + var iterator = barrier.events.makeAsyncIterator() + for _ in registrations { + _ = await iterator.next() + } + barrier.finish() } - /// Installs POSIX signal handlers for SIGINT and SIGWINCH. - /// - /// - SIGINT (Ctrl+C): Sets the shutdown flag for graceful cleanup. - /// - SIGWINCH (terminal resize): Sets the rerender flag. - /// - /// Signal handlers only set boolean flags — all actual work - /// happens in the main loop, which is async-signal-safe. - func install() { - signal(SIGINT) { _ in - signalNeedsShutdown = true - } - signal(SIGWINCH) { _ in - signalNeedsRerender = true - signalTerminalResized = true + /// Cancels all sources and restores the dispositions they replaced. + func stop() { + for registration in registrations { + registration.source.cancel() + #if !os(Linux) + signal(registration.number, registration.previousDisposition) + #endif } + registrations.removeAll() + } +} + +// MARK: - Private Helpers + +private extension SignalManager { + /// Registers one platform dispatch source for a POSIX signal. + func register( + _ number: Int32, + event: RuntimeEvent, + sendingTo channel: RuntimeEventChannel, + barrier: SignalRegistrationBarrier + ) { + #if os(Linux) + // swift-corelibs Dispatch installs and owns the sigaction used by + // its signalfd bridge. Replacing that disposition with SIG_IGN + // prevents delivery on Linux. + let previousDisposition: SignalDisposition? = nil + #else + // Darwin kqueue signal sources require the default disposition to + // be suppressed so termination work stays in the async runtime. + let previousDisposition = signal(number, SIG_IGN) + #endif + let source = DispatchSource.makeSignalSource( + signal: number, + queue: DispatchQueue.global(qos: .userInitiated) + ) + source.setEventHandler(handler: channel.sender(for: event)) + source.setRegistrationHandler(handler: barrier.callback()) + source.resume() + registrations.append( + Registration( + number: number, + previousDisposition: previousDisposition, + source: source + ) + ) } } diff --git a/Sources/TUIkitExample/main.swift b/Sources/TUIkitExample/ExampleApp.swift similarity index 78% rename from Sources/TUIkitExample/main.swift rename to Sources/TUIkitExample/ExampleApp.swift index f9ebd0a20..a254da440 100644 --- a/Sources/TUIkitExample/main.swift +++ b/Sources/TUIkitExample/ExampleApp.swift @@ -1,5 +1,5 @@ -// 🖥️ TUIKit — Terminal UI Kit for Swift -// main.swift +// 🖥️ TUIkit — Terminal UI Kit for Swift +// ExampleApp.swift // // Created by LAYERED.work // License: MIT @@ -12,6 +12,7 @@ import TUIkit // MARK: - Main App /// The main example application. +@main struct ExampleApp: App { var body: some Scene { WindowGroup { @@ -19,6 +20,3 @@ struct ExampleApp: App { } } } - -// Run the app -ExampleApp.main() diff --git a/Tests/TUIkitTests/AppRunnerTests.swift b/Tests/TUIkitTests/AppRunnerTests.swift new file mode 100644 index 000000000..a634c688c --- /dev/null +++ b/Tests/TUIkitTests/AppRunnerTests.swift @@ -0,0 +1,61 @@ +// 🖥️ TUIkit — Terminal UI Kit for Swift +// AppRunnerTests.swift +// +// License: MIT + +import Testing +import TUIkitTestSupport + +@testable import TUIkit + +@MainActor +@Suite("AppRunner Tests", .serialized) +struct AppRunnerTests { + @Test("App runner yields the MainActor to view tasks before shutdown") + func yieldsMainActorToViewTasks() async { + let events = TraceRecorder() + let eventChannel = RuntimeEventChannel() + let terminal = MockTerminal() + let context = TUIContext() + let runner = AppRunner( + app: MainActorTaskApp(events: events, eventChannel: eventChannel), + terminal: terminal, + tuiContext: context, + eventChannel: eventChannel, + inputSource: nil, + signals: nil + ) + + await runner.run() + + #expect(events.snapshot() == ["completed"]) + } +} + +@MainActor +private struct MainActorTaskApp: App { + let events: TraceRecorder + let eventChannel: RuntimeEventChannel + + init() { + self.events = TraceRecorder() + self.eventChannel = RuntimeEventChannel() + } + + init(events: TraceRecorder, eventChannel: RuntimeEventChannel) { + self.events = events + self.eventChannel = eventChannel + } + + var body: some Scene { + WindowGroup { + Text("Task") + .task { + await MainActor.run { + events.record("completed") + eventChannel.send(.shutdownRequested) + } + } + } + } +} diff --git a/Tests/TUIkitTests/RuntimeEventSourceTests.swift b/Tests/TUIkitTests/RuntimeEventSourceTests.swift new file mode 100644 index 000000000..2e93c367c --- /dev/null +++ b/Tests/TUIkitTests/RuntimeEventSourceTests.swift @@ -0,0 +1,81 @@ +// 🖥️ TUIkit — Terminal UI Kit for Swift +// RuntimeEventSourceTests.swift +// +// License: MIT + +import Testing + +#if canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#elseif canImport(Darwin) + import Darwin +#endif + +@testable import TUIkit + +@MainActor +@Suite("Runtime Event Source Tests", .serialized) +struct RuntimeEventSourceTests { + @Test("Signal sources deliver resize and termination events", .timeLimit(.minutes(1))) + func signalSourcesDeliverRuntimeEvents() async throws { + let channel = RuntimeEventChannel() + let manager = SignalManager() + await manager.install(sendingTo: channel) + defer { + manager.stop() + channel.finish() + } + + var iterator = channel.events.makeAsyncIterator() + + try #require(kill(getpid(), SIGWINCH) == 0) + let resizeEvent = await iterator.next() + if case .terminalResized = resizeEvent { + // Expected event. + } else { + Issue.record("Expected a terminal resize event") + } + + try #require(kill(getpid(), SIGTERM) == 0) + let shutdownEvent = await iterator.next() + if case .shutdownRequested = shutdownEvent { + // Expected event. + } else { + Issue.record("Expected a shutdown event") + } + } + + @Test("Input source wakes only when its descriptor becomes readable", .timeLimit(.minutes(1))) + func inputSourceDeliversReadiness() async throws { + var descriptors: [Int32] = [0, 0] + try #require(pipe(&descriptors) == 0) + defer { + close(descriptors[0]) + close(descriptors[1]) + } + + let channel = RuntimeEventChannel() + let source = TerminalInputSource(fileDescriptor: descriptors[0]) + source.start(sendingTo: channel) + defer { + source.stop() + channel.finish() + } + + var byte: UInt8 = 0x41 + try #require(write(descriptors[1], &byte, 1) == 1) + + var iterator = channel.events.makeAsyncIterator() + let event = await iterator.next() + var readByte: UInt8 = 0 + try #require(read(descriptors[0], &readByte, 1) == 1) + #expect(readByte == byte) + if case .inputAvailable = event { + // Expected event. + } else { + Issue.record("Expected an input readiness event") + } + } +} From 9ca8fecd828768c0a417f3b6fbee8a0551a68081 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 00:41:14 +0200 Subject: [PATCH 2/8] Refactor: Schedule animations from runtime deadlines - Derive focus and cursor phases from an injectable ContinuousClock. - Keep one deadline task only while visible focus animation is active. - Verify idle runtimes stay asleep and phase timing is deterministic. --- Sources/TUIkit/App/App.swift | 145 ++++++++++++++---- Sources/TUIkit/App/CursorTimer.swift | 71 ++++----- Sources/TUIkit/App/PulseTimer.swift | 77 +++------- .../App/RuntimeAnimationScheduler.swift | 53 +++++++ Sources/TUIkit/App/RuntimeEventSource.swift | 3 + .../Environment/RuntimeDependencies.swift | 54 ++++++- .../Environment/ServiceEnvironment.swift | 2 +- Sources/TUIkitView/State/State.swift | 5 +- Tests/TUIkitTests/AppRunnerTests.swift | 68 ++++++++ Tests/TUIkitTests/CursorTimerTests.swift | 30 ++++ Tests/TUIkitTests/PulseTimerTests.swift | 22 ++- .../RuntimeAnimationSchedulerTests.swift | 51 ++++++ .../Support/ManualTimeSource.swift | 34 ++++ 13 files changed, 470 insertions(+), 145 deletions(-) create mode 100644 Sources/TUIkit/App/RuntimeAnimationScheduler.swift create mode 100644 Tests/TUIkitTests/CursorTimerTests.swift create mode 100644 Tests/TUIkitTests/RuntimeAnimationSchedulerTests.swift create mode 100644 Tests/TUIkitTests/Support/ManualTimeSource.swift diff --git a/Sources/TUIkit/App/App.swift b/Sources/TUIkit/App/App.swift index aacfcb585..8e52aa685 100644 --- a/Sources/TUIkit/App/App.swift +++ b/Sources/TUIkit/App/App.swift @@ -110,8 +110,46 @@ internal final class AppRunner { extension AppRunner { func run() async { - // Create run-loop dependencies (previously IUOs, now local variables) - let inputHandler = InputHandler( + let inputHandler = makeInputHandler() + let renderer = makeRenderer() + let pulseTimer = PulseTimer(clock: tuiContext.clock) + let cursorTimer = CursorTimer(clock: tuiContext.clock) + let animationScheduler = RuntimeAnimationScheduler( + clock: tuiContext.clock, + eventChannel: eventChannel + ) + + await startRuntime(pulseTimer: pulseTimer, cursorTimer: cursorTimer) + defer { + stopRuntime( + pulseTimer: pulseTimer, + cursorTimer: cursorTimer, + animationScheduler: animationScheduler + ) + } + + render( + using: renderer, + pulseTimer: pulseTimer, + cursorTimer: cursorTimer, + animationScheduler: animationScheduler + ) + await processEvents( + using: inputHandler, + renderer: renderer, + pulseTimer: pulseTimer, + cursorTimer: cursorTimer, + animationScheduler: animationScheduler + ) + } +} + +// MARK: - Private Helpers + +private extension AppRunner { + /// Creates the runtime's input dispatcher. + func makeInputHandler() -> InputHandler { + InputHandler( statusBar: statusBar, keyEventDispatcher: tuiContext.keyEventDispatcher, focusManager: focusManager, @@ -121,7 +159,11 @@ extension AppRunner { eventChannel.send(.shutdownRequested) } ) - let renderer = RenderLoop( + } + + /// Creates the runtime's renderer. + func makeRenderer() -> RenderLoop { + RenderLoop( app: app, terminal: terminal, statusBar: statusBar, @@ -131,10 +173,10 @@ extension AppRunner { appearanceManager: appearanceManager, tuiContext: tuiContext ) - let pulseTimer = PulseTimer(renderNotifier: appState) - let cursorTimer = CursorTimer(renderNotifier: appState) + } - // Setup + /// Installs event sources and prepares the terminal session. + func startRuntime(pulseTimer: PulseTimer, cursorTimer: CursorTimer) async { if let signals { await signals.install(sendingTo: eventChannel) } @@ -143,52 +185,70 @@ extension AppRunner { terminal.hideCursor() terminal.enableRawMode() - // Register for state changes appState.observe { [eventChannel] in eventChannel.send(.renderRequested) } - - // Reset pulse animation and trigger re-render when focus changes let runtimeFocusChangeHandler = focusManager.onFocusChange focusManager.onFocusChange = { [weak pulseTimer] in pulseTimer?.reset() runtimeFocusChangeHandler?() } - - // Start animation timers pulseTimer.start() cursorTimer.start() + } - // Initial render - appState.didRender() - renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) - - defer { - pulseTimer.stop() - cursorTimer.stop() - inputSource?.stop() - signals?.stop() - eventChannel.finish() - cleanup() - } + /// Cancels runtime work and restores the terminal deterministically. + func stopRuntime( + pulseTimer: PulseTimer, + cursorTimer: CursorTimer, + animationScheduler: RuntimeAnimationScheduler + ) { + animationScheduler.stop() + pulseTimer.stop() + cursorTimer.stop() + inputSource?.stop() + signals?.stop() + eventChannel.finish() + cleanup() + } + /// Serially consumes state, input, signal, and animation events. + func processEvents( + using inputHandler: InputHandler, + renderer: RenderLoop, + pulseTimer: PulseTimer, + cursorTimer: CursorTimer, + animationScheduler: RuntimeAnimationScheduler + ) async { await withTaskCancellationHandler { var iterator = eventChannel.events.makeAsyncIterator() while let event = await iterator.next() { switch event { case .renderRequested: guard appState.needsRender else { continue } - appState.didRender() - renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) - + render( + using: renderer, + pulseTimer: pulseTimer, + cursorTimer: cursorTimer, + animationScheduler: animationScheduler + ) case .inputAvailable: processAvailableInput(using: inputHandler) - case .terminalResized: renderer.invalidateDiffCache() - appState.didRender() - renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) - + render( + using: renderer, + pulseTimer: pulseTimer, + cursorTimer: cursorTimer, + animationScheduler: animationScheduler + ) + case .animationDeadline: + render( + using: renderer, + pulseTimer: pulseTimer, + cursorTimer: cursorTimer, + animationScheduler: animationScheduler + ) case .shutdownRequested: return } @@ -197,11 +257,30 @@ extension AppRunner { eventChannel.send(.shutdownRequested) } } -} -// MARK: - Private Helpers + /// Renders one frame and schedules only the animation work it exposes. + func render( + using renderer: RenderLoop, + pulseTimer: PulseTimer, + cursorTimer: CursorTimer, + animationScheduler: RuntimeAnimationScheduler + ) { + appState.didRender() + renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) + animationScheduler.schedule(after: nextAnimationInterval) + } + + /// Returns the cadence required by currently visible focus animations. + var nextAnimationInterval: Double? { + if focusManager.hasTextInputFocus { + return 0.05 + } + if focusManager.currentFocused != nil || focusManager.activeSectionIdentifier != nil { + return 0.1 + } + return nil + } -private extension AppRunner { /// Drains a bounded batch after the input descriptor becomes readable. func processAvailableInput(using inputHandler: InputHandler) { var eventsProcessed = 0 diff --git a/Sources/TUIkit/App/CursorTimer.swift b/Sources/TUIkit/App/CursorTimer.swift index 6d8b5b3e7..92710e597 100644 --- a/Sources/TUIkit/App/CursorTimer.swift +++ b/Sources/TUIkit/App/CursorTimer.swift @@ -8,12 +8,12 @@ import Foundation /// Drives the cursor animation for TextField and SecureField. /// -/// `CursorTimer` maintains two phase values for different animation styles: +/// `CursorTimer` derives two phase values for different animation styles: /// - `blinkVisible`: Boolean for sharp on/off blinking /// - `pulsePhase`: Smooth 0-1 sine wave for pulsing /// -/// The timer runs independently from the `PulseTimer` (which handles focus indicators) -/// to allow different animation speeds and precise control over cursor timing. +/// The application event loop owns animation deadlines, so this type never +/// creates a background timer or invalidates an idle application. /// /// ## Animation Speeds /// @@ -25,7 +25,7 @@ import Foundation /// ## Usage /// /// ```swift -/// let cursor = CursorTimer(renderNotifier: appState) +/// let cursor = CursorTimer(clock: runtimeClock) /// cursor.start() /// // In render code: /// if cursor.blinkVisible(for: .regular) { @@ -33,30 +33,19 @@ import Foundation /// } /// let phase = cursor.pulsePhase(for: .regular) /// ``` +@MainActor final class CursorTimer { - /// Base tick interval in milliseconds. - /// We use a fast tick (50ms) and derive phases from elapsed time. - private let tickIntervalMs = 50 + /// Monotonic runtime clock used for phase calculation. + private let clock: RuntimeClock - /// Elapsed ticks since timer started. - private var elapsedTicks = 0 - - /// The GCD timer source. - private var timer: DispatchSourceTimer? - - /// The render notifier to trigger re-renders. - private weak var renderNotifier: AppState? + /// Monotonic timestamp at which the current cursor cycle began. + private var startTime: TimeInterval? /// Creates a new cursor timer. /// - /// - Parameter renderNotifier: The app state to notify when a re-render - /// is needed. Held weakly to avoid retain cycles. - init(renderNotifier: AppState) { - self.renderNotifier = renderNotifier - } - - deinit { - stop() + /// - Parameter clock: Monotonic runtime clock used for animation phases. + init(clock: RuntimeClock) { + self.clock = clock } } @@ -69,7 +58,7 @@ extension CursorTimer { /// - Returns: `true` if cursor should be visible, `false` if hidden. func blinkVisible(for speed: TextCursorStyle.Speed) -> Bool { let cycleMs = speed.blinkCycleMs - let elapsedMs = elapsedTicks * tickIntervalMs + let elapsedMs = elapsedMilliseconds let positionInCycle = elapsedMs % cycleMs // Visible for first half of cycle return positionInCycle < (cycleMs / 2) @@ -85,7 +74,7 @@ extension CursorTimer { /// - Returns: Phase value between 0 and 1. func pulsePhase(for speed: TextCursorStyle.Speed) -> Double { let cycleMs = speed.pulseCycleMs - let elapsedMs = elapsedTicks * tickIntervalMs + let elapsedMs = elapsedMilliseconds let positionInCycle = elapsedMs % cycleMs let normalized = Double(positionInCycle) / Double(cycleMs) // Sine wave: 0 → 1 → 0 over the cycle @@ -100,27 +89,13 @@ extension CursorTimer { /// /// If the timer is already running, this is a no-op. func start() { - guard timer == nil else { return } - - let source = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) - let interval = DispatchTimeInterval.milliseconds(tickIntervalMs) - source.schedule(deadline: .now() + interval, repeating: interval) - - source.setEventHandler { [weak self] in - guard let self else { return } - self.elapsedTicks += 1 - self.renderNotifier?.setNeedsRender() - } - - source.resume() - timer = source + guard startTime == nil else { return } + startTime = clock.now() } /// Stops the cursor animation timer. func stop() { - timer?.cancel() - timer = nil - elapsedTicks = 0 + startTime = nil } /// Resets the cursor animation to the visible/bright state. @@ -128,7 +103,17 @@ extension CursorTimer { /// Call this when a text field gains focus to ensure the cursor /// starts in a visible state. func reset() { - elapsedTicks = 0 + startTime = clock.now() + } +} + +// MARK: - Private Helpers + +private extension CursorTimer { + /// Elapsed whole milliseconds in the current cursor cycle. + var elapsedMilliseconds: Int { + guard let startTime else { return 0 } + return Int(max(0, clock.now() - startTime) * 1_000) } } diff --git a/Sources/TUIkit/App/PulseTimer.swift b/Sources/TUIkit/App/PulseTimer.swift index 4f2278d52..87ddb0ade 100644 --- a/Sources/TUIkit/App/PulseTimer.swift +++ b/Sources/TUIkit/App/PulseTimer.swift @@ -8,47 +8,34 @@ import Foundation /// Drives the breathing animation for the active focus section indicator. /// -/// `PulseTimer` maintains a phase value (0–1) that oscillates smoothly -/// using a sine curve. On each step, it calls `setNeedsRender()` to -/// trigger a re-render with the updated phase. -/// -/// The timer runs on its own `DispatchSourceTimer`, completely independent -/// from the Spinner animation (which uses Swift Concurrency tasks) and -/// the RenderLoop (which renders on demand via `AppState.needsRender`). +/// `PulseTimer` derives a phase value (0–1) from the runtime's monotonic +/// clock. The application event loop owns animation deadlines, so this type +/// never creates a background timer or invalidates an idle application. /// /// ## Breathing Cycle /// -/// - The phase follows `sin(step * π / totalSteps)`, producing a smooth +/// - The phase follows a sine curve, producing a smooth /// 0 → 1 → 0 oscillation. -/// - Default: 10 steps at 300ms each = 3 second cycle. /// - At phase 0: color is dimmed (20% of accent). At phase 1: full accent. /// /// ## Usage /// /// ```swift -/// let pulse = PulseTimer(renderNotifier: appState) +/// let pulse = PulseTimer(clock: runtimeClock) /// pulse.start() /// // ... later /// pulse.stop() /// ``` +@MainActor final class PulseTimer { - /// The number of discrete steps in a half-cycle (dim → bright). - /// - /// A full breathing cycle (dim → bright → dim) is `totalHalfSteps * 2` steps. - /// At 100ms per step and 10 half-steps: full cycle = 20 × 100ms = 2 seconds. - private let totalHalfSteps = 10 - - /// The interval between steps in milliseconds. - private let stepIntervalMs = 100 + /// Duration of one dim → bright → dim cycle. + private let cycleDuration: TimeInterval = 2 - /// The current step in the full cycle (0 ..< totalHalfSteps * 2). - private var currentStep = 0 + /// Monotonic runtime clock used for phase calculation. + private let clock: RuntimeClock - /// The GCD timer source. - private var timer: DispatchSourceTimer? - - /// The render notifier to trigger re-renders. - private weak var renderNotifier: AppState? + /// Monotonic timestamp at which the current cycle began. + private var startTime: TimeInterval? /// The current pulse phase (0–1), computed from the current step. /// @@ -57,22 +44,19 @@ final class PulseTimer { /// - Step totalHalfSteps: phase = 1 (brightest) /// - Step totalHalfSteps * 2: phase = 0 (dimmest, cycle repeats) var phase: Double { - let fullCycle = totalHalfSteps * 2 - let normalized = Double(currentStep) / Double(fullCycle) + guard let startTime else { return 0 } + let elapsed = max(0, clock.now() - startTime) + let position = elapsed.truncatingRemainder(dividingBy: cycleDuration) + let normalized = position / cycleDuration // sin(0) = 0, sin(π) = 0, peak at sin(π/2) = 1 return sin(normalized * .pi) } /// Creates a new pulse timer. /// - /// - Parameter renderNotifier: The app state to notify when a re-render - /// is needed. Held weakly to avoid retain cycles. - init(renderNotifier: AppState) { - self.renderNotifier = renderNotifier - } - - deinit { - stop() + /// - Parameter clock: Monotonic runtime clock used for animation phases. + init(clock: RuntimeClock) { + self.clock = clock } } @@ -83,27 +67,13 @@ extension PulseTimer { /// /// If the timer is already running, this is a no-op. func start() { - guard timer == nil else { return } - - let source = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) - let interval = DispatchTimeInterval.milliseconds(stepIntervalMs) - source.schedule(deadline: .now() + interval, repeating: interval) - - source.setEventHandler { [weak self] in - guard let self else { return } - self.currentStep = (self.currentStep + 1) % (self.totalHalfSteps * 2) - self.renderNotifier?.setNeedsRender() - } - - source.resume() - timer = source + guard startTime == nil else { return } + startTime = clock.now() } /// Stops the breathing animation. func stop() { - timer?.cancel() - timer = nil - currentStep = 0 + startTime = nil } /// Resets the animation to the brightest point (phase = 1). @@ -111,7 +81,6 @@ extension PulseTimer { /// Called when focus changes to make the indicator immediately visible /// on the newly focused element instead of continuing mid-cycle. func reset() { - // Set to peak brightness (step = totalHalfSteps → phase = 1.0) - currentStep = totalHalfSteps + startTime = clock.now() - cycleDuration / 2 } } diff --git a/Sources/TUIkit/App/RuntimeAnimationScheduler.swift b/Sources/TUIkit/App/RuntimeAnimationScheduler.swift new file mode 100644 index 000000000..ddb4423c4 --- /dev/null +++ b/Sources/TUIkit/App/RuntimeAnimationScheduler.swift @@ -0,0 +1,53 @@ +// 🖥️ TUIkit — Terminal UI Kit for Swift +// RuntimeAnimationScheduler.swift +// +// License: MIT + +// MARK: - Runtime Animation Scheduler + +/// Owns the single pending animation deadline for an application runtime. +@MainActor +internal final class RuntimeAnimationScheduler { + /// Monotonic clock used to suspend until the next frame deadline. + private let clock: RuntimeClock + + /// Runtime channel receiving completed animation deadlines. + private let eventChannel: RuntimeEventChannel + + /// Currently pending deadline task, if animation is active. + private var deadlineTask: Task? + + /// Creates a scheduler owned by one application runtime. + init(clock: RuntimeClock, eventChannel: RuntimeEventChannel) { + self.clock = clock + self.eventChannel = eventChannel + } +} + +// MARK: - Internal API + +extension RuntimeAnimationScheduler { + /// Replaces the pending deadline, or suspends animation when no interval is supplied. + func schedule(after interval: Double?) { + deadlineTask?.cancel() + deadlineTask = nil + + guard let interval else { return } + let deadline = clock.now() + interval + deadlineTask = Task { [clock, eventChannel] in + do { + try await clock.sleep(until: deadline) + } catch { + return + } + guard !Task.isCancelled else { return } + eventChannel.send(.animationDeadline) + } + } + + /// Cancels the pending deadline during runtime shutdown. + func stop() { + deadlineTask?.cancel() + deadlineTask = nil + } +} diff --git a/Sources/TUIkit/App/RuntimeEventSource.swift b/Sources/TUIkit/App/RuntimeEventSource.swift index 9c0ba5292..0af7b1fc9 100644 --- a/Sources/TUIkit/App/RuntimeEventSource.swift +++ b/Sources/TUIkit/App/RuntimeEventSource.swift @@ -26,6 +26,9 @@ internal enum RuntimeEvent: Sendable { /// The terminal dimensions changed. case terminalResized + /// The active animation reached its next frame deadline. + case animationDeadline + /// The application should shut down gracefully. case shutdownRequested } diff --git a/Sources/TUIkit/Environment/RuntimeDependencies.swift b/Sources/TUIkit/Environment/RuntimeDependencies.swift index 4d557e03c..7dc1d73a2 100644 --- a/Sources/TUIkit/Environment/RuntimeDependencies.swift +++ b/Sources/TUIkit/Environment/RuntimeDependencies.swift @@ -8,25 +8,69 @@ import Foundation // MARK: - Runtime Clock -/// Injectable wall clock used by time-based rendering services. +/// Injectable monotonic clock used by time-based rendering services. struct RuntimeClock: Sendable { - /// System wall clock used by production runtimes. - static let system = Self { - Date().timeIntervalSinceReferenceDate - } + /// Continuous system clock used by production runtimes. + static let system: Self = { + let clock = ContinuousClock() + let origin = clock.now + + return Self( + now: { + origin.duration(to: clock.now).timeInterval + }, + sleepUntil: { deadline in + try await clock.sleep( + until: origin.advanced(by: .seconds(deadline)) + ) + } + ) + }() /// Provider returning seconds since the Foundation reference date. private let nowProvider: @Sendable () -> TimeInterval + /// Provider suspending until the supplied monotonic timestamp. + private let sleepUntilProvider: @Sendable (TimeInterval) async throws -> Void + /// Creates a clock backed by the supplied provider. init(now: @escaping @Sendable () -> TimeInterval) { self.nowProvider = now + let clock = ContinuousClock() + self.sleepUntilProvider = { deadline in + let remaining = max(0, deadline - now()) + try await clock.sleep(for: .seconds(remaining)) + } + } + + /// Creates a fully injectable clock for deterministic deadline tests. + init( + now: @escaping @Sendable () -> TimeInterval, + sleepUntil: @escaping @Sendable (TimeInterval) async throws -> Void + ) { + self.nowProvider = now + self.sleepUntilProvider = sleepUntil } /// Returns the current wall-clock value. func now() -> TimeInterval { nowProvider() } + + /// Suspends until the supplied monotonic timestamp. + func sleep(until deadline: TimeInterval) async throws { + try await sleepUntilProvider(deadline) + } +} + +// MARK: - Duration Conversion + +private extension Duration { + /// Exact duration represented as fractional seconds. + var timeInterval: TimeInterval { + let components = self.components + return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18 + } } // MARK: - Volatile Storage diff --git a/Sources/TUIkit/Environment/ServiceEnvironment.swift b/Sources/TUIkit/Environment/ServiceEnvironment.swift index 217534df4..555709c29 100644 --- a/Sources/TUIkit/Environment/ServiceEnvironment.swift +++ b/Sources/TUIkit/Environment/ServiceEnvironment.swift @@ -57,7 +57,7 @@ private struct PulsePhaseKey: EnvironmentKey { /// EnvironmentKey for TextField/SecureField cursor blink animation. private struct CursorTimerKey: EnvironmentKey { - nonisolated(unsafe) static let defaultValue: CursorTimer? = nil + static let defaultValue: CursorTimer? = nil } // MARK: - Focus Indicator Color diff --git a/Sources/TUIkitView/State/State.swift b/Sources/TUIkitView/State/State.swift index d4b10185c..128aa9999 100644 --- a/Sources/TUIkitView/State/State.swift +++ b/Sources/TUIkitView/State/State.swift @@ -11,9 +11,8 @@ import TUIkitCore /// Application state that triggers re-renders when modified. /// -/// `AppState` is thread-safe: ``setNeedsRender()`` can be called from any thread -/// (e.g., from `PulseTimer` on a background queue). Internal state is protected -/// by an `NSLock`. +/// `AppState` is thread-safe: ``setNeedsRender()`` can be called from any thread. +/// Internal state is protected by an `NSLock`. /// /// The `AppRunner` subscribes to state changes and re-renders when notified. /// Property wrappers route changes to the runtime-owned instance through diff --git a/Tests/TUIkitTests/AppRunnerTests.swift b/Tests/TUIkitTests/AppRunnerTests.swift index a634c688c..04313e9ab 100644 --- a/Tests/TUIkitTests/AppRunnerTests.swift +++ b/Tests/TUIkitTests/AppRunnerTests.swift @@ -30,6 +30,40 @@ struct AppRunnerTests { #expect(events.snapshot() == ["completed"]) } + + @Test("Idle app does not request periodic renders", .timeLimit(.minutes(1))) + func idleAppDoesNotRequestPeriodicRenders() async throws { + let invalidations = TraceRecorder() + let taskStarted = AsyncSignal() + let releaseTask = AsyncSignal() + let eventChannel = RuntimeEventChannel() + let context = TUIContext() + context.appState.observe { + invalidations.record("render") + } + let runner = AppRunner( + app: IdleTaskApp( + taskStarted: taskStarted, + releaseTask: releaseTask, + eventChannel: eventChannel + ), + terminal: MockTerminal(), + tuiContext: context, + eventChannel: eventChannel, + inputSource: nil, + signals: nil + ) + + let runTask = Task { + await runner.run() + } + await taskStarted.wait() + try await ContinuousClock().sleep(for: .milliseconds(250)) + releaseTask.signal() + await runTask.value + + #expect(invalidations.snapshot().isEmpty) + } } @MainActor @@ -59,3 +93,37 @@ private struct MainActorTaskApp: App { } } } + +@MainActor +private struct IdleTaskApp: App { + let taskStarted: AsyncSignal + let releaseTask: AsyncSignal + let eventChannel: RuntimeEventChannel + + init() { + self.taskStarted = AsyncSignal() + self.releaseTask = AsyncSignal() + self.eventChannel = RuntimeEventChannel() + } + + init( + taskStarted: AsyncSignal, + releaseTask: AsyncSignal, + eventChannel: RuntimeEventChannel + ) { + self.taskStarted = taskStarted + self.releaseTask = releaseTask + self.eventChannel = eventChannel + } + + var body: some Scene { + WindowGroup { + Text("Idle") + .task { + taskStarted.signal() + await releaseTask.wait() + eventChannel.send(.shutdownRequested) + } + } + } +} diff --git a/Tests/TUIkitTests/CursorTimerTests.swift b/Tests/TUIkitTests/CursorTimerTests.swift new file mode 100644 index 000000000..fe2ef1690 --- /dev/null +++ b/Tests/TUIkitTests/CursorTimerTests.swift @@ -0,0 +1,30 @@ +// 🖥️ TUIkit — Terminal UI Kit for Swift +// CursorTimerTests.swift +// +// License: MIT + +import Testing + +@testable import TUIkit + +@MainActor +@Suite("CursorTimer Tests") +struct CursorTimerTests { + @Test("Blink and pulse phases follow the injected monotonic clock") + func phasesFollowClock() { + let timeSource = ManualTimeSource() + let timer = CursorTimer(clock: RuntimeClock { timeSource.now() }) + + timer.start() + #expect(timer.blinkVisible(for: .regular)) + + timeSource.advance(by: 0.33) + #expect(timer.blinkVisible(for: .regular) == false) + + timeSource.advance(by: 0.07) + #expect(abs(timer.pulsePhase(for: .regular) - 1) < 0.000_001) + + timeSource.advance(by: 0.26) + #expect(timer.blinkVisible(for: .regular)) + } +} diff --git a/Tests/TUIkitTests/PulseTimerTests.swift b/Tests/TUIkitTests/PulseTimerTests.swift index da634ee4b..603819917 100644 --- a/Tests/TUIkitTests/PulseTimerTests.swift +++ b/Tests/TUIkitTests/PulseTimerTests.swift @@ -14,15 +14,13 @@ struct PulseTimerTests { @Test("Initial phase is zero") func initialPhaseZero() { - let appState = AppState() - let timer = PulseTimer(renderNotifier: appState) + let timer = PulseTimer(clock: RuntimeClock { 0 }) #expect(timer.phase == 0) } @Test("Phase stays within 0-1 range") func phaseRange() { - let appState = AppState() - let timer = PulseTimer(renderNotifier: appState) + let timer = PulseTimer(clock: RuntimeClock { 0 }) // Phase is computed from sin(), which for our mapping gives 0–1 let phase = timer.phase @@ -31,8 +29,7 @@ struct PulseTimerTests { @Test("Start and stop are balanced") func startStopBalanced() { - let appState = AppState() - let timer = PulseTimer(renderNotifier: appState) + let timer = PulseTimer(clock: RuntimeClock { 0 }) // Should not crash when stopped without starting timer.stop() @@ -46,4 +43,17 @@ struct PulseTimerTests { timer.start() timer.stop() } + + @Test("Phase follows the injected monotonic clock") + func phaseFollowsClock() { + let timeSource = ManualTimeSource() + let timer = PulseTimer(clock: RuntimeClock { timeSource.now() }) + + timer.start() + timeSource.advance(by: 1) + #expect(abs(timer.phase - 1) < 0.000_001) + + timeSource.advance(by: 1) + #expect(abs(timer.phase) < 0.000_001) + } } diff --git a/Tests/TUIkitTests/RuntimeAnimationSchedulerTests.swift b/Tests/TUIkitTests/RuntimeAnimationSchedulerTests.swift new file mode 100644 index 000000000..118bf2491 --- /dev/null +++ b/Tests/TUIkitTests/RuntimeAnimationSchedulerTests.swift @@ -0,0 +1,51 @@ +// 🖥️ TUIkit — Terminal UI Kit for Swift +// RuntimeAnimationSchedulerTests.swift +// +// License: MIT + +import Testing +import TUIkitTestSupport + +@testable import TUIkit + +@MainActor +@Suite("Runtime Animation Scheduler Tests") +struct RuntimeAnimationSchedulerTests { + @Test("Injected clock controls the next animation deadline", .timeLimit(.minutes(1))) + func injectedClockControlsDeadline() async { + let deadlines = TraceRecorder() + let sleepStarted = AsyncSignal() + let releaseSleep = AsyncSignal() + let channel = RuntimeEventChannel() + let clock = RuntimeClock( + now: { 10 }, + sleepUntil: { deadline in + deadlines.record(deadline) + sleepStarted.signal() + await releaseSleep.wait() + try Task.checkCancellation() + } + ) + let scheduler = RuntimeAnimationScheduler( + clock: clock, + eventChannel: channel + ) + defer { + scheduler.stop() + channel.finish() + } + + scheduler.schedule(after: 0.05) + await sleepStarted.wait() + #expect(deadlines.snapshot() == [10.05]) + + releaseSleep.signal() + var iterator = channel.events.makeAsyncIterator() + let event = await iterator.next() + if case .animationDeadline = event { + // Expected event. + } else { + Issue.record("Expected an animation deadline event") + } + } +} diff --git a/Tests/TUIkitTests/Support/ManualTimeSource.swift b/Tests/TUIkitTests/Support/ManualTimeSource.swift new file mode 100644 index 000000000..dea01a812 --- /dev/null +++ b/Tests/TUIkitTests/Support/ManualTimeSource.swift @@ -0,0 +1,34 @@ +// 🖥️ TUIkit — Terminal UI Kit for Swift +// ManualTimeSource.swift +// +// License: MIT + +import Foundation + +/// Thread-safe mutable time source for deterministic phase tests. +final class ManualTimeSource: @unchecked Sendable { + /// Lock protecting the current timestamp. + private let lock = NSLock() + + /// Current monotonic timestamp in seconds. + private var timestamp: TimeInterval + + /// Creates a source at the supplied timestamp. + init(now: TimeInterval = 0) { + self.timestamp = now + } + + /// Returns the current timestamp. + func now() -> TimeInterval { + lock.lock() + defer { lock.unlock() } + return timestamp + } + + /// Advances the timestamp by a deterministic duration. + func advance(by interval: TimeInterval) { + lock.lock() + timestamp += interval + lock.unlock() + } +} From 6c882614eb4f2ab020fde4382d50f68e4109f2e0 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 00:46:34 +0200 Subject: [PATCH 3/8] Refactor: Inject terminal system calls - Route terminal I/O through injectable POSIX calls. - Allow isolated descriptors in integration tests. - Preserve current terminal behavior. --- Sources/TUIkit/Rendering/Terminal.swift | 103 +++++++++++++++++++++--- 1 file changed, 90 insertions(+), 13 deletions(-) diff --git a/Sources/TUIkit/Rendering/Terminal.swift b/Sources/TUIkit/Rendering/Terminal.swift index 6c4820afe..0f0950e54 100644 --- a/Sources/TUIkit/Rendering/Terminal.swift +++ b/Sources/TUIkit/Rendering/Terminal.swift @@ -14,6 +14,57 @@ import Foundation import Darwin #endif +// MARK: - Terminal System Calls + +/// Injectable POSIX calls used by terminal input and output. +internal struct TerminalSystemCalls: Sendable { + /// Reads bytes from a file descriptor. + let read: @Sendable (Int32, UnsafeMutableRawPointer?, Int) -> Int + + /// Writes bytes to a file descriptor. + let write: @Sendable (Int32, UnsafeRawPointer?, Int) -> Int + + /// Returns the current thread-local POSIX error code. + let errorCode: @Sendable () -> Int32 + + /// Production calls supplied by the active platform module. + static let system = Self( + read: platformRead, + write: platformWrite, + errorCode: { errno } + ) +} + +/// Calls the active platform's POSIX `read` function. +private func platformRead( + _ fileDescriptor: Int32, + _ buffer: UnsafeMutableRawPointer?, + _ count: Int +) -> Int { + #if canImport(Glibc) + Glibc.read(fileDescriptor, buffer, count) + #elseif canImport(Musl) + Musl.read(fileDescriptor, buffer, count) + #else + Darwin.read(fileDescriptor, buffer, count) + #endif +} + +/// Calls the active platform's POSIX `write` function. +private func platformWrite( + _ fileDescriptor: Int32, + _ buffer: UnsafeRawPointer?, + _ count: Int +) -> Int { + #if canImport(Glibc) + Glibc.write(fileDescriptor, buffer, count) + #elseif canImport(Musl) + Musl.write(fileDescriptor, buffer, count) + #else + Darwin.write(fileDescriptor, buffer, count) + #endif +} + /// Platform-specific type for `termios` flag fields. /// /// Darwin uses `UInt` (64-bit), Linux uses `tcflag_t` (`UInt32`). @@ -48,6 +99,15 @@ import Foundation /// on the main thread, which is enforced by the Swift concurrency system. @MainActor final class Terminal: TerminalProtocol { + /// File descriptor used for terminal input. + private let inputFileDescriptor: Int32 + + /// File descriptor used for terminal output. + private let outputFileDescriptor: Int32 + + /// POSIX calls used for input and output. + private let systemCalls: TerminalSystemCalls + /// Whether raw mode is active. private var isRawMode = false @@ -67,7 +127,14 @@ final class Terminal: TerminalProtocol { private var frameBuffer: [UInt8] = [] /// Creates a new terminal instance. - init() { + init( + inputFileDescriptor: Int32 = STDIN_FILENO, + outputFileDescriptor: Int32 = STDOUT_FILENO, + systemCalls: TerminalSystemCalls = .system + ) { + self.inputFileDescriptor = inputFileDescriptor + self.outputFileDescriptor = outputFileDescriptor + self.systemCalls = systemCalls frameBuffer.reserveCapacity(16_384) } @@ -95,9 +162,9 @@ extension Terminal { var windowSize = winsize() #if canImport(Glibc) || canImport(Musl) - let result = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &windowSize) + let result = ioctl(outputFileDescriptor, UInt(TIOCGWINSZ), &windowSize) #else - let result = ioctl(STDOUT_FILENO, TIOCGWINSZ, &windowSize) + let result = ioctl(outputFileDescriptor, TIOCGWINSZ, &windowSize) #endif if result == 0 && windowSize.ws_col > 0 && windowSize.ws_row > 0 { @@ -121,7 +188,7 @@ extension Terminal { guard !isRawMode else { return } var raw = termios() - tcgetattr(STDIN_FILENO, &raw) + tcgetattr(inputFileDescriptor, &raw) originalTermios = raw raw.c_lflag &= ~TermFlag(ECHO | ICANON | ISIG | IEXTEN) @@ -137,7 +204,7 @@ extension Terminal { } } - tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) + tcsetattr(inputFileDescriptor, TCSAFLUSH, &raw) isRawMode = true // Enable bracketed paste mode so that terminal paste operations @@ -154,7 +221,7 @@ extension Terminal { // Disable bracketed paste mode before restoring terminal state. writeImmediate("\u{1B}[?2004l") - tcsetattr(STDIN_FILENO, TCSAFLUSH, &original) + tcsetattr(inputFileDescriptor, TCSAFLUSH, &original) isRawMode = false } @@ -231,7 +298,9 @@ extension Terminal { /// - Returns: The bytes read, or empty array on timeout/error. func readBytes(maxBytes: Int = 8) -> [UInt8] { var buffer = [UInt8](repeating: 0, count: 1) - let bytesRead = read(STDIN_FILENO, &buffer, 1) + let bytesRead = buffer.withUnsafeMutableBytes { bytes in + systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) + } guard bytesRead > 0 else { return [] } @@ -244,7 +313,9 @@ extension Terminal { var result: [UInt8] = [0x1B] var nextByte = [UInt8](repeating: 0, count: 1) - let nextRead = read(STDIN_FILENO, &nextByte, 1) + let nextRead = nextByte.withUnsafeMutableBytes { bytes in + systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) + } guard nextRead > 0 else { // Just ESC alone return result @@ -256,7 +327,9 @@ extension Terminal { if nextByte[0] == 0x5B { // '[' // Read until we find a CSI terminator (letter A-Za-z or ~) for _ in 0..<(maxBytes - 2) { - let paramRead = read(STDIN_FILENO, &nextByte, 1) + let paramRead = nextByte.withUnsafeMutableBytes { bytes in + systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) + } guard paramRead > 0 else { break } result.append(nextByte[0]) @@ -269,7 +342,9 @@ extension Terminal { } } else if nextByte[0] == 0x4F { // SS3 sequence: ESC O // Read one more byte for F1-F4 keys - let funcRead = read(STDIN_FILENO, &nextByte, 1) + let funcRead = nextByte.withUnsafeMutableBytes { bytes in + systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) + } if funcRead > 0 { result.append(nextByte[0]) } @@ -317,7 +392,9 @@ extension Terminal { while content.count < maxPasteBytes { var byte = [UInt8](repeating: 0, count: 1) - let bytesRead = read(STDIN_FILENO, &byte, 1) + let bytesRead = byte.withUnsafeMutableBytes { bytes in + systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) + } guard bytesRead > 0 else { // No more data available right now. For non-blocking reads // (VMIN=0, VTIME=0) this means the paste end marker has not @@ -359,7 +436,7 @@ private extension Terminal { let count = buffer.count var written = 0 while written < count { - let result = Foundation.write(STDOUT_FILENO, baseAddress + written, count - written) + let result = systemCalls.write(outputFileDescriptor, baseAddress + written, count - written) if result <= 0 { break } written += result } @@ -376,7 +453,7 @@ private extension Terminal { baseAddress.withMemoryRebound(to: UInt8.self, capacity: count) { pointer in var written = 0 while written < count { - let result = Foundation.write(STDOUT_FILENO, pointer + written, count - written) + let result = systemCalls.write(outputFileDescriptor, pointer + written, count - written) if result <= 0 { break } written += result } From 7cd7c15ade08fe27964e3956a8061d3540271a43 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 00:52:58 +0200 Subject: [PATCH 4/8] Fix: Handle interrupted terminal I/O - Retry interrupted reads and interrupted or partial writes. - Preserve the first permanent POSIX failure for runtime handling. - Cover retry, partial-write, and hard-error behavior. --- Sources/TUIkit/Rendering/Terminal.swift | 135 +++++++----- .../TUIkit/Rendering/TerminalProtocol.swift | 32 +++ Tests/TUIkitTests/TerminalIOTests.swift | 199 ++++++++++++++++++ 3 files changed, 318 insertions(+), 48 deletions(-) create mode 100644 Tests/TUIkitTests/TerminalIOTests.swift diff --git a/Sources/TUIkit/Rendering/Terminal.swift b/Sources/TUIkit/Rendering/Terminal.swift index 0f0950e54..cd6dd0f5e 100644 --- a/Sources/TUIkit/Rendering/Terminal.swift +++ b/Sources/TUIkit/Rendering/Terminal.swift @@ -98,7 +98,7 @@ private func platformWrite( /// `Terminal` is `@MainActor` isolated. All terminal operations must occur /// on the main thread, which is enforced by the Swift concurrency system. @MainActor -final class Terminal: TerminalProtocol { +final class Terminal: TerminalProtocol, TerminalFailureReporting { /// File descriptor used for terminal input. private let inputFileDescriptor: Int32 @@ -108,6 +108,9 @@ final class Terminal: TerminalProtocol { /// POSIX calls used for input and output. private let systemCalls: TerminalSystemCalls + /// The first terminal I/O failure not yet consumed by the runtime. + private var pendingIOFailure: TerminalIOFailure? + /// Whether raw mode is active. private var isRawMode = false @@ -288,6 +291,12 @@ extension Terminal { write(ANSIRenderer.exitAlternateScreen) } + /// Removes and returns the first pending terminal I/O failure. + func takeIOFailure() -> TerminalIOFailure? { + defer { pendingIOFailure = nil } + return pendingIOFailure + } + /// Reads raw bytes from the terminal, handling escape sequences. /// /// Reads exactly one key event worth of bytes. For escape sequences, @@ -297,56 +306,40 @@ extension Terminal { /// - Parameter maxBytes: Maximum bytes to read (default: 8). /// - Returns: The bytes read, or empty array on timeout/error. func readBytes(maxBytes: Int = 8) -> [UInt8] { - var buffer = [UInt8](repeating: 0, count: 1) - let bytesRead = buffer.withUnsafeMutableBytes { bytes in - systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) - } - - guard bytesRead > 0 else { return [] } + guard let firstByte = readByte() else { return [] } // Not an escape sequence - return single byte - guard buffer[0] == 0x1B else { - return [buffer[0]] + guard firstByte == 0x1B else { + return [firstByte] } // Read the next byte to determine sequence type var result: [UInt8] = [0x1B] - var nextByte = [UInt8](repeating: 0, count: 1) - - let nextRead = nextByte.withUnsafeMutableBytes { bytes in - systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) - } - guard nextRead > 0 else { + guard let nextByte = readByte() else { // Just ESC alone return result } - result.append(nextByte[0]) + result.append(nextByte) // CSI sequence: ESC [ - if nextByte[0] == 0x5B { // '[' + if nextByte == 0x5B { // '[' // Read until we find a CSI terminator (letter A-Za-z or ~) for _ in 0..<(maxBytes - 2) { - let paramRead = nextByte.withUnsafeMutableBytes { bytes in - systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) - } - guard paramRead > 0 else { break } + guard let parameterByte = readByte() else { break } - result.append(nextByte[0]) + result.append(parameterByte) // CSI terminators: letters (0x40-0x7E) mark end of sequence // Common: A-D (arrows), H/F (home/end), Z (shift-tab), ~ (extended) - if nextByte[0] >= 0x40 && nextByte[0] <= 0x7E { + if parameterByte >= 0x40 && parameterByte <= 0x7E { break } } - } else if nextByte[0] == 0x4F { // SS3 sequence: ESC O + } else if nextByte == 0x4F { // SS3 sequence: ESC O // Read one more byte for F1-F4 keys - let funcRead = nextByte.withUnsafeMutableBytes { bytes in - systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) - } - if funcRead > 0 { - result.append(nextByte[0]) + if let functionByte = readByte() { + result.append(functionByte) } } // Alt+key: ESC followed by single key - already have both bytes @@ -391,11 +384,7 @@ extension Terminal { let maxPasteBytes = 65_536 while content.count < maxPasteBytes { - var byte = [UInt8](repeating: 0, count: 1) - let bytesRead = byte.withUnsafeMutableBytes { bytes in - systemCalls.read(inputFileDescriptor, bytes.baseAddress, 1) - } - guard bytesRead > 0 else { + guard let byte = readByte() else { // No more data available right now. For non-blocking reads // (VMIN=0, VTIME=0) this means the paste end marker has not // yet arrived. Wait briefly and retry. @@ -403,7 +392,7 @@ extension Terminal { continue } - content.append(byte[0]) + content.append(byte) // Check if content ends with the paste end marker. if content.count >= endMarker.count { @@ -423,6 +412,46 @@ extension Terminal { // MARK: - Private Helpers private extension Terminal { + /// Reads one byte, retrying when the system call is interrupted. + func readByte() -> UInt8? { + var byte: UInt8 = 0 + + while true { + let result = withUnsafeMutableBytes(of: &byte) { buffer in + systemCalls.read(inputFileDescriptor, buffer.baseAddress, 1) + } + + if result > 0 { + return byte + } + if result < 0, systemCalls.errorCode() == EINTR { + continue + } + if result < 0 { + recordIOFailure( + operation: .read, + errorCode: systemCalls.errorCode(), + remainingByteCount: 1 + ) + } + return nil + } + } + + /// Records the first terminal I/O failure until the runtime consumes it. + func recordIOFailure( + operation: TerminalIOFailure.Operation, + errorCode: Int32, + remainingByteCount: Int + ) { + guard pendingIOFailure == nil else { return } + pendingIOFailure = TerminalIOFailure( + operation: operation, + errorCode: errorCode, + remainingByteCount: remainingByteCount + ) + } + /// Appends a string's UTF-8 bytes to the frame buffer. func appendToBuffer(_ string: String) { frameBuffer.append(contentsOf: string.utf8) @@ -433,13 +462,7 @@ private extension Terminal { guard !frameBuffer.isEmpty else { return } frameBuffer.withUnsafeBufferPointer { buffer in guard let baseAddress = buffer.baseAddress else { return } - let count = buffer.count - var written = 0 - while written < count { - let result = systemCalls.write(outputFileDescriptor, baseAddress + written, count - written) - if result <= 0 { break } - written += result - } + writeBytes(baseAddress, count: buffer.count) } frameBuffer.removeAll(keepingCapacity: true) } @@ -451,12 +474,28 @@ private extension Terminal { let count = buffer.count - 1 guard count >= 1, let baseAddress = buffer.baseAddress else { return } baseAddress.withMemoryRebound(to: UInt8.self, capacity: count) { pointer in - var written = 0 - while written < count { - let result = systemCalls.write(outputFileDescriptor, pointer + written, count - written) - if result <= 0 { break } - written += result - } + writeBytes(pointer, count: count) + } + } + } + + /// Writes every byte, retrying interrupted and partial system calls. + func writeBytes(_ baseAddress: UnsafePointer, count: Int) { + var written = 0 + + while written < count { + let result = systemCalls.write(outputFileDescriptor, baseAddress + written, count - written) + if result > 0 { + written += result + } else if result < 0, systemCalls.errorCode() == EINTR { + continue + } else { + recordIOFailure( + operation: .write, + errorCode: result < 0 ? systemCalls.errorCode() : EIO, + remainingByteCount: count - written + ) + return } } } diff --git a/Sources/TUIkit/Rendering/TerminalProtocol.swift b/Sources/TUIkit/Rendering/TerminalProtocol.swift index 1ff0dda7b..1fca8dd5f 100644 --- a/Sources/TUIkit/Rendering/TerminalProtocol.swift +++ b/Sources/TUIkit/Rendering/TerminalProtocol.swift @@ -93,3 +93,35 @@ public protocol TerminalProtocol: AnyObject, Sendable { /// Exits the alternate screen buffer. func exitAlternateScreen() } + +// MARK: - Terminal I/O Failure Reporting + +/// A terminal system call that failed permanently. +internal struct TerminalIOFailure: Error, Equatable, Sendable, CustomStringConvertible { + /// The terminal operation that failed. + enum Operation: String, Equatable, Sendable { + case read + case write + } + + /// The terminal operation that failed. + let operation: Operation + + /// The POSIX error code returned by the platform. + let errorCode: Int32 + + /// Bytes that could not be transferred. + let remainingByteCount: Int + + var description: String { + "Terminal \(operation.rawValue) failed with POSIX error \(errorCode); " + + "\(remainingByteCount) byte(s) remained." + } +} + +/// Supplies terminal I/O failures to the application runtime. +@MainActor +internal protocol TerminalFailureReporting: AnyObject { + /// Removes and returns the first pending terminal I/O failure. + func takeIOFailure() -> TerminalIOFailure? +} diff --git a/Tests/TUIkitTests/TerminalIOTests.swift b/Tests/TUIkitTests/TerminalIOTests.swift new file mode 100644 index 000000000..1ef0771e1 --- /dev/null +++ b/Tests/TUIkitTests/TerminalIOTests.swift @@ -0,0 +1,199 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// TerminalIOTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation +import Testing + +#if canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#elseif canImport(Darwin) + import Darwin +#endif + +@testable import TUIkit + +// MARK: - Terminal I/O Tests + +@Suite("Terminal I/O Tests") +@MainActor +struct TerminalIOTests { + @Test("Reading retries after an interrupted system call") + func readRetriesAfterInterruption() { + let script = TerminalIOScript( + reads: [.failure(EINTR), .bytes([0x41])] + ) + let terminal = Terminal(systemCalls: script.systemCalls) + + #expect(terminal.readBytes() == [0x41]) + #expect(script.readCallCount == 2) + } + + @Test("Writing retries interruptions and partial writes") + func writeRetriesInterruptionsAndPartialWrites() { + let script = TerminalIOScript( + writes: [.failure(EINTR), .count(2), .count(3)] + ) + let terminal = Terminal(systemCalls: script.systemCalls) + + terminal.write("Hello") + + #expect(script.writtenBytes == Array("Hello".utf8)) + #expect(script.writeCallCount == 3) + } + + @Test("A read failure is reported once") + func readFailureIsReportedOnce() { + let script = TerminalIOScript(reads: [.failure(EIO)]) + let terminal = Terminal(systemCalls: script.systemCalls) + + #expect(terminal.readBytes().isEmpty) + #expect( + terminal.takeIOFailure() == TerminalIOFailure( + operation: .read, + errorCode: EIO, + remainingByteCount: 1 + ) + ) + #expect(terminal.takeIOFailure() == nil) + } + + @Test("A write failure reports its remaining byte count") + func writeFailureReportsRemainingBytes() { + let script = TerminalIOScript( + writes: [.count(2), .failure(EIO)] + ) + let terminal = Terminal(systemCalls: script.systemCalls) + + terminal.write("Hello") + + #expect(script.writtenBytes == Array("He".utf8)) + #expect( + terminal.takeIOFailure() == TerminalIOFailure( + operation: .write, + errorCode: EIO, + remainingByteCount: 3 + ) + ) + #expect(terminal.takeIOFailure() == nil) + } +} + +// MARK: - Test Support + +private final class TerminalIOScript: @unchecked Sendable { + enum ReadStep: Sendable { + case failure(Int32) + case bytes([UInt8]) + } + + enum WriteStep: Sendable { + case failure(Int32) + case count(Int) + } + + private let lock = NSLock() + private var reads: [ReadStep] + private var writes: [WriteStep] + private var currentErrorCode: Int32 = 0 + private var storedReadCallCount = 0 + private var storedWriteCallCount = 0 + private var storedWrittenBytes: [UInt8] = [] + + init( + reads: [ReadStep] = [], + writes: [WriteStep] = [] + ) { + self.reads = reads + self.writes = writes + } + + var systemCalls: TerminalSystemCalls { + TerminalSystemCalls( + read: { [self] fileDescriptor, buffer, count in + read(fileDescriptor: fileDescriptor, into: buffer, count: count) + }, + write: { [self] fileDescriptor, buffer, count in + write(fileDescriptor: fileDescriptor, from: buffer, count: count) + }, + errorCode: { [self] in errorCode } + ) + } + + var readCallCount: Int { + withLock { storedReadCallCount } + } + + var writeCallCount: Int { + withLock { storedWriteCallCount } + } + + var writtenBytes: [UInt8] { + withLock { storedWrittenBytes } + } +} + +private extension TerminalIOScript { + var errorCode: Int32 { + withLock { currentErrorCode } + } + + func read( + fileDescriptor _: Int32, + into buffer: UnsafeMutableRawPointer?, + count: Int + ) -> Int { + withLock { + storedReadCallCount += 1 + guard !reads.isEmpty else { return 0 } + + switch reads.removeFirst() { + case let .failure(errorCode): + currentErrorCode = errorCode + return -1 + case let .bytes(bytes): + currentErrorCode = 0 + guard let buffer else { return 0 } + let byteCount = min(count, bytes.count) + for index in 0.. Int { + withLock { + storedWriteCallCount += 1 + guard !writes.isEmpty else { return 0 } + + switch writes.removeFirst() { + case let .failure(errorCode): + currentErrorCode = errorCode + return -1 + case let .count(requestedCount): + currentErrorCode = 0 + guard let buffer else { return 0 } + let byteCount = min(count, requestedCount) + let bytes = buffer.assumingMemoryBound(to: UInt8.self) + storedWrittenBytes.append(contentsOf: UnsafeBufferPointer(start: bytes, count: byteCount)) + return byteCount + } + } + } + + func withLock(_ body: () -> Result) -> Result { + lock.lock() + defer { lock.unlock() } + return body() + } +} From 831b0bd2448e50b06811db4846bc08e1b185e1d7 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 00:53:03 +0200 Subject: [PATCH 5/8] Fix: Surface terminal failures from app runtime - Throw terminal I/O failures from the asynchronous app entry point. - Restore terminal state before propagating runtime failures. - Verify error propagation and cleanup through AppRunner. --- Sources/TUIkit/App/App.swift | 67 ++++++++++++++-------- Tests/TUIkitTests/AppRunnerTests.swift | 41 +++++++++++-- Tests/TUIkitTests/Mocks/MockTerminal.swift | 11 +++- 3 files changed, 91 insertions(+), 28 deletions(-) diff --git a/Sources/TUIkit/App/App.swift b/Sources/TUIkit/App/App.swift index 8e52aa685..45ec53eae 100644 --- a/Sources/TUIkit/App/App.swift +++ b/Sources/TUIkit/App/App.swift @@ -50,10 +50,10 @@ extension App { /// This method is called by the `@main` attribute and starts /// the main run loop of the application. /// - public static func main() async { + public static func main() async throws { let app = Self() let runner = AppRunner(app: app) - await runner.run() + try await runner.run() } } @@ -109,7 +109,7 @@ internal final class AppRunner { // MARK: - Internal API extension AppRunner { - func run() async { + func run() async throws { let inputHandler = makeInputHandler() let renderer = makeRenderer() let pulseTimer = PulseTimer(clock: tuiContext.clock) @@ -120,27 +120,36 @@ extension AppRunner { ) await startRuntime(pulseTimer: pulseTimer, cursorTimer: cursorTimer) - defer { + do { + try throwPendingTerminalFailure() + try render( + using: renderer, + pulseTimer: pulseTimer, + cursorTimer: cursorTimer, + animationScheduler: animationScheduler + ) + try await processEvents( + using: inputHandler, + renderer: renderer, + pulseTimer: pulseTimer, + cursorTimer: cursorTimer, + animationScheduler: animationScheduler + ) + } catch { stopRuntime( pulseTimer: pulseTimer, cursorTimer: cursorTimer, animationScheduler: animationScheduler ) + throw error } - render( - using: renderer, - pulseTimer: pulseTimer, - cursorTimer: cursorTimer, - animationScheduler: animationScheduler - ) - await processEvents( - using: inputHandler, - renderer: renderer, + stopRuntime( pulseTimer: pulseTimer, cursorTimer: cursorTimer, animationScheduler: animationScheduler ) + try throwPendingTerminalFailure() } } @@ -219,31 +228,31 @@ private extension AppRunner { pulseTimer: PulseTimer, cursorTimer: CursorTimer, animationScheduler: RuntimeAnimationScheduler - ) async { - await withTaskCancellationHandler { + ) async throws { + try await withTaskCancellationHandler { var iterator = eventChannel.events.makeAsyncIterator() while let event = await iterator.next() { switch event { case .renderRequested: guard appState.needsRender else { continue } - render( + try render( using: renderer, pulseTimer: pulseTimer, cursorTimer: cursorTimer, animationScheduler: animationScheduler ) case .inputAvailable: - processAvailableInput(using: inputHandler) + try processAvailableInput(using: inputHandler) case .terminalResized: renderer.invalidateDiffCache() - render( + try render( using: renderer, pulseTimer: pulseTimer, cursorTimer: cursorTimer, animationScheduler: animationScheduler ) case .animationDeadline: - render( + try render( using: renderer, pulseTimer: pulseTimer, cursorTimer: cursorTimer, @@ -264,9 +273,10 @@ private extension AppRunner { pulseTimer: PulseTimer, cursorTimer: CursorTimer, animationScheduler: RuntimeAnimationScheduler - ) { + ) throws { appState.didRender() renderer.render(pulsePhase: pulseTimer.phase, cursorTimer: cursorTimer) + try throwPendingTerminalFailure() animationScheduler.schedule(after: nextAnimationInterval) } @@ -282,17 +292,28 @@ private extension AppRunner { } /// Drains a bounded batch after the input descriptor becomes readable. - func processAvailableInput(using inputHandler: InputHandler) { + func processAvailableInput(using inputHandler: InputHandler) throws { var eventsProcessed = 0 let maxEventsPerBatch = 128 - while eventsProcessed < maxEventsPerBatch, - let keyEvent = terminal.readKeyEvent() { + while eventsProcessed < maxEventsPerBatch { + let keyEvent = terminal.readKeyEvent() + try throwPendingTerminalFailure() + guard let keyEvent else { return } inputHandler.handle(keyEvent) eventsProcessed += 1 } } + /// Throws the first terminal I/O failure exposed by the concrete terminal. + func throwPendingTerminalFailure() throws { + guard let failureReporter = terminal as? any TerminalFailureReporting, + let failure = failureReporter.takeIOFailure() else { + return + } + throw failure + } + func cleanup() { terminal.disableRawMode() terminal.showCursor() diff --git a/Tests/TUIkitTests/AppRunnerTests.swift b/Tests/TUIkitTests/AppRunnerTests.swift index 04313e9ab..1c4d6bfde 100644 --- a/Tests/TUIkitTests/AppRunnerTests.swift +++ b/Tests/TUIkitTests/AppRunnerTests.swift @@ -12,7 +12,7 @@ import TUIkitTestSupport @Suite("AppRunner Tests", .serialized) struct AppRunnerTests { @Test("App runner yields the MainActor to view tasks before shutdown") - func yieldsMainActorToViewTasks() async { + func yieldsMainActorToViewTasks() async throws { let events = TraceRecorder() let eventChannel = RuntimeEventChannel() let terminal = MockTerminal() @@ -26,7 +26,7 @@ struct AppRunnerTests { signals: nil ) - await runner.run() + try await runner.run() #expect(events.snapshot() == ["completed"]) } @@ -55,15 +55,48 @@ struct AppRunnerTests { ) let runTask = Task { - await runner.run() + try await runner.run() } await taskStarted.wait() try await ContinuousClock().sleep(for: .milliseconds(250)) releaseTask.signal() - await runTask.value + try await runTask.value #expect(invalidations.snapshot().isEmpty) } + + @Test("Terminal failures are thrown after terminal cleanup") + func terminalFailureIsThrownAfterCleanup() async { + let expectedFailure = TerminalIOFailure( + operation: .write, + errorCode: 5, + remainingByteCount: 4 + ) + let terminal = MockTerminal() + terminal.pendingIOFailure = expectedFailure + let eventChannel = RuntimeEventChannel() + let runner = AppRunner( + app: MainActorTaskApp(events: TraceRecorder(), eventChannel: eventChannel), + terminal: terminal, + tuiContext: TUIContext(), + eventChannel: eventChannel, + inputSource: nil, + signals: nil + ) + + do { + try await runner.run() + Issue.record("Expected the terminal failure to be thrown") + } catch let failure as TerminalIOFailure { + #expect(failure == expectedFailure) + } catch { + Issue.record("Unexpected error: \(error)") + } + + #expect(!terminal.isRawModeEnabled) + #expect(!terminal.isCursorHidden) + #expect(!terminal.isInAlternateScreen) + } } @MainActor diff --git a/Tests/TUIkitTests/Mocks/MockTerminal.swift b/Tests/TUIkitTests/Mocks/MockTerminal.swift index 71859451e..bdcb9ed76 100644 --- a/Tests/TUIkitTests/Mocks/MockTerminal.swift +++ b/Tests/TUIkitTests/Mocks/MockTerminal.swift @@ -35,7 +35,7 @@ /// } /// ``` @MainActor -final class MockTerminal: TerminalProtocol { +final class MockTerminal: TerminalProtocol, TerminalFailureReporting { /// The simulated terminal size. var size: (width: Int, height: Int) = (80, 24) @@ -62,6 +62,9 @@ final class MockTerminal: TerminalProtocol { /// Whether frame buffering is active. private var isBuffering = false + /// A terminal I/O failure waiting for the runtime to consume it. + var pendingIOFailure: TerminalIOFailure? + /// Buffer for collecting writes during a frame. private var frameBuffer: [String] = [] @@ -134,6 +137,11 @@ extension MockTerminal { isInAlternateScreen = false write(ANSIRenderer.exitAlternateScreen) } + + func takeIOFailure() -> TerminalIOFailure? { + defer { pendingIOFailure = nil } + return pendingIOFailure + } } // MARK: - Test Helpers @@ -148,6 +156,7 @@ extension MockTerminal { isCursorHidden = false isInAlternateScreen = false isBuffering = false + pendingIOFailure = nil cursorPosition = (1, 1) size = (80, 24) } From cfb6afbac063d05a181a58e20c9db6644c9117cc Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 00:55:23 +0200 Subject: [PATCH 6/8] Test: Verify deterministic runtime shutdown - Confirm shutdown cancels active view tasks. - Assert raw mode, cursor state, and alternate screen are restored. --- Tests/TUIkitTests/AppRunnerTests.swift | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/Tests/TUIkitTests/AppRunnerTests.swift b/Tests/TUIkitTests/AppRunnerTests.swift index 1c4d6bfde..40e9065d9 100644 --- a/Tests/TUIkitTests/AppRunnerTests.swift +++ b/Tests/TUIkitTests/AppRunnerTests.swift @@ -97,6 +97,39 @@ struct AppRunnerTests { #expect(!terminal.isCursorHidden) #expect(!terminal.isInAlternateScreen) } + + @Test("Shutdown cancels view tasks and restores the terminal", .timeLimit(.minutes(1))) + func shutdownCancelsTasksAndRestoresTerminal() async throws { + let taskStarted = AsyncSignal() + let taskCancelled = AsyncSignal() + let releaseTask = AsyncSignal() + let eventChannel = RuntimeEventChannel() + let terminal = MockTerminal() + let runner = AppRunner( + app: ShutdownTaskApp( + taskStarted: taskStarted, + taskCancelled: taskCancelled, + releaseTask: releaseTask + ), + terminal: terminal, + tuiContext: TUIContext(), + eventChannel: eventChannel, + inputSource: nil, + signals: nil + ) + + let runTask = Task { + try await runner.run() + } + await taskStarted.wait() + eventChannel.send(.shutdownRequested) + try await runTask.value + await taskCancelled.wait() + + #expect(!terminal.isRawModeEnabled) + #expect(!terminal.isCursorHidden) + #expect(!terminal.isInAlternateScreen) + } } @MainActor @@ -160,3 +193,40 @@ private struct IdleTaskApp: App { } } } + +@MainActor +private struct ShutdownTaskApp: App { + let taskStarted: AsyncSignal + let taskCancelled: AsyncSignal + let releaseTask: AsyncSignal + + init() { + self.taskStarted = AsyncSignal() + self.taskCancelled = AsyncSignal() + self.releaseTask = AsyncSignal() + } + + init( + taskStarted: AsyncSignal, + taskCancelled: AsyncSignal, + releaseTask: AsyncSignal + ) { + self.taskStarted = taskStarted + self.taskCancelled = taskCancelled + self.releaseTask = releaseTask + } + + var body: some Scene { + WindowGroup { + Text("Running") + .task { + taskStarted.signal() + await withTaskCancellationHandler { + await releaseTask.wait() + } onCancel: { + taskCancelled.signal() + } + } + } + } +} From bd1e429367364e1397e1f54721959ee96f066477 Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 01:00:40 +0200 Subject: [PATCH 7/8] Docs: Describe the async app runtime - Replace polling and signal-flag descriptions with runtime events. - Document idle suspension, deadline scheduling, and deterministic cleanup. - Clarify terminal retries and animation phase behavior. --- Sources/TUIkit/App/CursorTimer.swift | 14 +-- Sources/TUIkit/App/PulseTimer.swift | 4 +- Sources/TUIkit/App/RenderLoop.swift | 9 +- .../NotificationHostModifier.swift | 4 +- Sources/TUIkit/Rendering/Terminal.swift | 11 +- .../TUIkit.docc/Articles/AppLifecycle.md | 102 +++++++++++------- .../TUIkit.docc/Articles/Architecture.md | 28 +++-- .../TUIkit.docc/Articles/RenderCycle.md | 30 ++++-- Sources/TUIkit/Views/Spinner.swift | 2 +- Sources/TUIkit/Views/_ImageCore.swift | 3 +- 10 files changed, 127 insertions(+), 80 deletions(-) diff --git a/Sources/TUIkit/App/CursorTimer.swift b/Sources/TUIkit/App/CursorTimer.swift index 92710e597..fe142d391 100644 --- a/Sources/TUIkit/App/CursorTimer.swift +++ b/Sources/TUIkit/App/CursorTimer.swift @@ -18,9 +18,9 @@ import Foundation /// ## Animation Speeds /// /// The speed is controlled by ``TextCursorStyle/Speed``: -/// - `.slow`: 800ms cycle (visible 400ms, hidden 400ms) -/// - `.regular`: 530ms cycle (visible 265ms, hidden 265ms) -/// - `.fast`: 300ms cycle (visible 150ms, hidden 150ms) +/// - `.slow`: 1000ms cycle (visible 500ms, hidden 500ms) +/// - `.regular`: 660ms cycle (visible 330ms, hidden 330ms) +/// - `.fast`: 400ms cycle (visible 200ms, hidden 200ms) /// /// ## Usage /// @@ -82,18 +82,18 @@ extension CursorTimer { } } -// MARK: - Timer Control +// MARK: - Phase Control extension CursorTimer { - /// Starts the cursor animation timer. + /// Starts the cursor animation phase clock. /// - /// If the timer is already running, this is a no-op. + /// If the phase clock is already active, this is a no-op. func start() { guard startTime == nil else { return } startTime = clock.now() } - /// Stops the cursor animation timer. + /// Stops the cursor animation phase clock. func stop() { startTime = nil } diff --git a/Sources/TUIkit/App/PulseTimer.swift b/Sources/TUIkit/App/PulseTimer.swift index 87ddb0ade..c95e23cbf 100644 --- a/Sources/TUIkit/App/PulseTimer.swift +++ b/Sources/TUIkit/App/PulseTimer.swift @@ -65,13 +65,13 @@ final class PulseTimer { extension PulseTimer { /// Starts the breathing animation. /// - /// If the timer is already running, this is a no-op. + /// If the phase clock is already active, this is a no-op. func start() { guard startTime == nil else { return } startTime = clock.now() } - /// Stops the breathing animation. + /// Stops phase calculation for the breathing animation. func stop() { startTime = nil } diff --git a/Sources/TUIkit/App/RenderLoop.swift b/Sources/TUIkit/App/RenderLoop.swift index 78f8b3535..1f2c1a1bb 100644 --- a/Sources/TUIkit/App/RenderLoop.swift +++ b/Sources/TUIkit/App/RenderLoop.swift @@ -75,7 +75,7 @@ internal struct RenderBackgroundCodes: Equatable { /// 8. Begin buffered frame (terminal.beginFrame()) /// 9. Diff against previous frame, write only changed lines to buffer /// 10. Render status bar into same buffer (with its own diff tracking) -/// 11. Flush entire frame in one write() syscall (terminal.endFrame()) +/// 11. Flush the entire frame (normally one write() syscall) /// 12. End lifecycle tracking (fires onDisappear for removed views) /// ``` /// @@ -88,9 +88,10 @@ internal struct RenderBackgroundCodes: Equatable { /// ## Output Buffering /// /// All diff writes (content + status bar) are collected in `Terminal`'s -/// frame buffer and flushed as a single `write()` syscall via +/// frame buffer and normally flushed as a single `write()` syscall via /// `Terminal.beginFrame()` / `Terminal.endFrame()`. This reduces -/// per-frame syscalls from ~40+ to exactly 1. +/// per-frame syscalls from ~40+ to one unless the platform reports an +/// interruption or partial transfer that must be retried. /// /// On terminal resize (SIGWINCH), the diff cache is invalidated to force /// a full repaint. @@ -311,7 +312,7 @@ private extension RenderLoop { /// Writes the assembled frame to the terminal using diff-based output. /// /// Builds terminal-ready output lines, then writes app header, content, - /// and status bar inside a single buffered frame (one `write()` syscall). + /// and status bar inside a single buffered frame (normally one syscall). func writeFrame( buffer: FrameBuffer, environment: EnvironmentValues, diff --git a/Sources/TUIkit/Notification/NotificationHostModifier.swift b/Sources/TUIkit/Notification/NotificationHostModifier.swift index 15ae09fc4..ebcb4c434 100644 --- a/Sources/TUIkit/Notification/NotificationHostModifier.swift +++ b/Sources/TUIkit/Notification/NotificationHostModifier.swift @@ -51,7 +51,7 @@ extension NotificationHostModifier: Renderable { return baseBuffer } - // Start the animation timer if not already running. + // Start the animation invalidation task if not already running. startAnimationTask( entries: activeEntries, lifecycle: context.environment.lifecycle!, @@ -166,7 +166,7 @@ private extension NotificationHostModifier { .max() ?? 0 lifecycle.startTask(token: token, priority: .medium) { [lifecycle, invalidationSink] in - let triggerNanos: UInt64 = 23_800_000 // ~24ms (~42 FPS) + let triggerNanos: UInt64 = 23_800_000 // ~42 animation frames per second while !Task.isCancelled { let now = clock.now() diff --git a/Sources/TUIkit/Rendering/Terminal.swift b/Sources/TUIkit/Rendering/Terminal.swift index cd6dd0f5e..696fa8912 100644 --- a/Sources/TUIkit/Rendering/Terminal.swift +++ b/Sources/TUIkit/Rendering/Terminal.swift @@ -81,14 +81,15 @@ private func platformWrite( /// - Terminal size queries /// - Raw mode configuration /// - Safe input and output -/// - Frame-buffered output (all writes collected, flushed in one syscall) +/// - Frame-buffered output (all writes collected, normally flushed in one syscall) /// /// ## Output Buffering /// /// During rendering, call ``beginFrame()`` before writing and ``endFrame()`` /// after. All ``write(_:)`` calls between them are collected in an internal -/// `[UInt8]` buffer and flushed as a single `write()` syscall, reducing -/// per-frame syscalls from ~40+ to exactly 1. +/// `[UInt8]` buffer and normally flushed as a single `write()` syscall, reducing +/// per-frame syscalls from ~40+ to one in the normal case. Interrupted and +/// partial writes are retried until the frame is complete or an error is surfaced. /// /// Outside of a frame (setup, teardown), ``write(_:)`` writes immediately /// as before — safe by default. @@ -232,7 +233,7 @@ extension Terminal { /// /// After this call, all ``write(_:)`` calls append to an internal /// `[UInt8]` buffer instead of issuing syscalls. Call ``endFrame()`` - /// to flush the collected output in a single `write()` syscall. + /// to flush the collected output, normally in a single `write()` syscall. func beginFrame() { guard !isBuffering else { return } isBuffering = true @@ -457,7 +458,7 @@ private extension Terminal { frameBuffer.append(contentsOf: string.utf8) } - /// Writes all buffered bytes to `STDOUT_FILENO` in a single syscall. + /// Writes all buffered bytes, retrying interrupted or partial syscalls. func flushBuffer() { guard !frameBuffer.isEmpty else { return } frameBuffer.withUnsafeBufferPointer { buffer in diff --git a/Sources/TUIkit/TUIkit.docc/Articles/AppLifecycle.md b/Sources/TUIkit/TUIkit.docc/Articles/AppLifecycle.md index 65faf386d..14042810b 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/AppLifecycle.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/AppLifecycle.md @@ -4,7 +4,10 @@ Understand how a TUIkit application starts, runs, and shuts down. ## Overview -A TUIkit application follows a linear lifecycle: **launch → setup → main loop → cleanup**. The framework handles terminal configuration, signal handling, and the render-input cycle so you can focus on building views. +A TUIkit application follows a linear lifecycle: **launch → setup → initial +render → async event loop → cleanup**. The framework handles terminal +configuration, signal handling, and the render-input cycle so you can focus on +building views. ## Entry Point @@ -36,7 +39,9 @@ The `@main` attribute tells Swift to call the static `main()` method provided by `AppRunner.init()` creates a terminal session, a signal manager, and one `TUIContext`. The context is the sole owner of the application's mutable runtime services. `run()` then creates the session components that consume those services: -`InputHandler`, `RenderLoop`, `PulseTimer`, and `CursorTimer`. +`InputHandler`, `RenderLoop`, `RuntimeEventChannel`, input and signal sources, +and `RuntimeAnimationScheduler`. `PulseTimer` and `CursorTimer` derive visual +phases from the runtime clock; they do not own background timers. ``` AppRunner @@ -51,8 +56,6 @@ AppRunner └── ImageLoader, URLImageCache ``` -@Image(source: "lifecycle-run-creates.png", alt: "Diagram showing run() creating InputHandler, RenderLoop, PulseTimer (100ms), and CursorTimer (50ms).") - `AppRunner` owns the session. `TUIContext` owns the view-facing runtime services. Dependencies flow through constructor injection and ``EnvironmentValues`` inside ``RenderContext``. The render context itself never owns or accesses a terminal. @@ -63,14 +66,15 @@ Before the main loop starts, `run()` prepares the terminal: | Step | What | Why | |------|------|-----| -| 1 | Install signal handlers | Catch Ctrl+C (SIGINT) and terminal resize (SIGWINCH) | -| 2 | Enter alternate screen | Preserve the user's existing terminal content | -| 3 | Hide cursor | Avoid cursor flicker during rendering | -| 4 | Enable raw mode | Disable line buffering, echo, and signal processing | -| 5 | Register state observer | `AppState` changes trigger re-renders via `signals.requestRerender()` | -| 6 | Register focus observer | Focus changes reset the pulse timer and trigger re-renders | -| 7 | Start timers | PulseTimer (100 ms) and CursorTimer (50 ms) for animations | -| 8 | Render first frame | Show the initial UI immediately | +| 1 | Install dispatch signal sources | Deliver SIGINT, SIGTERM, and SIGWINCH as runtime events | +| 2 | Start the input source | Deliver descriptor readiness without polling | +| 3 | Enter alternate screen | Preserve the user's existing terminal content | +| 4 | Hide cursor | Avoid cursor flicker during rendering | +| 5 | Enable raw mode | Disable line buffering, echo, and terminal signal processing | +| 6 | Register state observer | Send `.renderRequested` when `AppState` changes | +| 7 | Register focus observer | Reset the pulse phase and request a render | +| 8 | Initialize animation phase clocks | Establish monotonic phase origins without starting timers | +| 9 | Render first frame | Show the initial UI immediately | ### Raw Mode @@ -79,39 +83,55 @@ In raw mode, the terminal delivers every keystroke immediately without waiting f - **No echo**: typed characters are not displayed - **No canonical mode**: input is byte-by-byte, not line-by-line - **No signal processing**: Ctrl+C is handled by TUIkit, not the OS -- **100ms read timeout**: non-blocking input polling +- **Non-blocking reads**: input is drained only after the descriptor becomes readable The original terminal settings are saved and restored during cleanup. ## Main Loop -The main loop is synchronous and runs until shutdown: +The main loop is asynchronous. It awaits the next runtime event instead of +polling, and processes one event at a time: + +| Event | Runtime action | +|-------|----------------| +| `.renderRequested` | Render if `AppState` still needs a frame | +| `.inputAvailable` | Drain and dispatch a bounded batch of key events | +| `.terminalResized` | Invalidate the terminal diff cache and render | +| `.animationDeadline` | Render the next visible focus-animation phase | +| `.shutdownRequested` | Leave the loop and begin cleanup | -@Image(source: "lifecycle-main-loop.png", alt: "Flowchart of the main loop: run() performs terminal setup, registers observers, starts timers, renders first frame, then loops checking shouldShutdown, consumeResizeFlag to invalidate diff cache, rerenderFlag or needsRender to conditionally render, reads key events non-blocking up to 128 per frame, dispatches through 5 layers, and sleeps 28ms. On shouldShutdown, cleanup restores the terminal and exits.") +When no event or animation deadline is pending, the task remains suspended and +the idle application does not wake periodically. ### Re-render Triggers -Several sources cause a new frame to be rendered, all converging on two boolean checks in the main loop: +Several sources cause a new frame to be rendered through the runtime event +channel: -| Trigger | Path | Main loop check | -|---------|------|-----------------| -| SIGWINCH | Sets `signalNeedsRerender` + `signalTerminalResized` | `consumeRerenderFlag()` | -| @State mutation | Owning runtime invalidates its subtree and notifies `AppState` | `consumeRerenderFlag()` | -| PulseTimer / CursorTimer | Calls `appState.setNeedsRender()` | `appState.needsRender` | -| Focus change | Calls `appState.setNeedsRender()` | `appState.needsRender` | +| Trigger | Path | Runtime event | +|---------|------|---------------| +| SIGWINCH | `DispatchSourceSignal` | `.terminalResized` | +| @State mutation | Owning runtime invalidates its subtree and notifies `AppState` | `.renderRequested` | +| Focus animation deadline | `RuntimeAnimationScheduler` | `.animationDeadline` | +| View-owned animation | Invalidates `AppState` | `.renderRequested` | +| Focus change | Resets pulse phase and invalidates `AppState` | `.renderRequested` | -All triggers set boolean flags. The actual rendering always happens on the main thread: signal handlers never render directly. +The actual rendering always happens on the main actor. Event producers never +render directly. ## Signal Handling -`SignalManager` installs two POSIX signal handlers: +`SignalManager` installs dispatch-backed sources for three POSIX signals: | Signal | Trigger | Effect | |--------|---------|--------| -| `SIGINT` | Ctrl+C | Sets a shutdown flag → main loop exits | -| `SIGWINCH` | Terminal resize | Sets a re-render flag → next iteration re-renders | +| `SIGINT` | Ctrl+C | Sends `.shutdownRequested` | +| `SIGTERM` | Process termination request | Sends `.shutdownRequested` | +| `SIGWINCH` | Terminal resize | Sends `.terminalResized` | -Signal handlers only set `nonisolated(unsafe)` boolean flags: no allocations, no locks. The main loop reads these flags each iteration and acts accordingly. +No POSIX handler mutates Swift global state. Dispatch bridges delivery into the +runtime channel, where the events are serialized with state, input, rendering, +and shutdown. ## Key Event Dispatch @@ -159,25 +179,31 @@ Each frame follows 12 steps inside `RenderLoop.render()`: | 8 | Begin buffered frame (`Terminal.beginFrame()`) | | 9 | Render app header, diff and write only changed content lines | | 10 | Render status bar into same buffer | -| 11 | Flush entire frame in one `write()` syscall (`Terminal.endFrame()`) | +| 11 | Flush the buffered frame, normally with one `write()` syscall (`Terminal.endFrame()`) | | 12 | End lifecycle tracking (fires `onDisappear` for removed views) | -Steps 8–11 are the output optimization layer: line-level diffing reduces writes by ~94% for static UIs, and frame buffering reduces syscalls from ~40+ to exactly 1. +Steps 8–11 are the output optimization layer: line-level diffing reduces writes +by ~94% for static UIs, and frame buffering normally reduces the frame flush to +one syscall while still retrying interrupted or partial writes. > For full details on each step, see . ## Cleanup -When the main loop exits: via Ctrl+C, the quit key, or programmatic shutdown: `cleanup()` restores the terminal: +When the event loop exits via a signal, the quit key, programmatic shutdown, or +a surfaced terminal I/O failure, the runtime first stops its event sources and +then restores the terminal: | Step | What | Why | |------|------|-----| -| 1 | Disable raw mode | Restore original terminal settings | -| 2 | Show cursor | Make the cursor visible again | -| 3 | Exit alternate screen | Restore the user's previous terminal content | -| 4 | Clear state observers | Remove `AppState` observer callbacks | -| 5 | Clear focus | Remove all focus registrations | -| 6 | Reset TUIContext | Clear runtime state, lifecycle, handlers, caches, notifications, and focus; synchronize app storage | +| 1 | Stop animation, input, and signal sources | Prevent new runtime work | +| 2 | Finish the event channel | Resume and terminate pending consumers | +| 3 | Disable raw mode | Restore original terminal settings | +| 4 | Show cursor | Make the cursor visible again | +| 5 | Exit alternate screen | Restore the user's previous terminal content | +| 6 | Clear state observers | Remove `AppState` observer callbacks | +| 7 | Clear focus | Remove all focus registrations | +| 8 | Reset TUIContext | Cancel view tasks and clear runtime state, handlers, caches, notifications, and focus; synchronize app storage | The `Terminal` class also has a `deinit` safety net that disables raw mode if it was not explicitly restored. @@ -195,5 +221,5 @@ caches, focus, localization, notifications, storage, or image requests. During each frame, `RenderLoop` obtains a complete environment from `TUIContext`, renders into a ``FrameBuffer``, then writes that buffer through the injected terminal session. `InputHandler` receives the same context-owned focus, status -bar, key dispatch, palette, and appearance services. Timers invalidate only that -context's `AppState`. +bar, key dispatch, palette, and appearance services. Runtime events and +animation deadlines remain scoped to that runner. diff --git a/Sources/TUIkit/TUIkit.docc/Articles/Architecture.md b/Sources/TUIkit/TUIkit.docc/Articles/Architecture.md index 36ddad780..c471d8e64 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/Architecture.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/Architecture.md @@ -10,10 +10,10 @@ TUIkit is structured in six layers, each building on the one below. This clean s ### 1. App Layer -The ``App`` protocol is the entry point. It defines one or more scenes that make up your application. The internal `AppRunner` manages the main run loop, terminal setup, signal handling, and event dispatching. +The ``App`` protocol is the entry point. It defines one or more scenes that make up your application. The internal `AppRunner` manages the async event loop, terminal setup, signal handling, and event dispatching. ``` -@main → App → AppRunner → Main Loop +@main → App → AppRunner → RuntimeEventChannel ``` ### 2. View Layer @@ -82,13 +82,23 @@ and URL loading feed data into that deterministic decoder rather than participat `AppRunner` owns the terminal session and signal manager plus one `TUIContext`. That context owns the application's render state, storage, caches, localization, notifications, focus, themes, image loading, and other view-facing services. -`AppRunner` creates `InputHandler` and `RenderLoop`, installs POSIX signal handlers, -sets up the terminal, starts `PulseTimer` and `CursorTimer`, registers state and -focus observers, and performs an initial render before entering the main loop. - -Each loop iteration checks `shouldShutdown` (set by SIGINT), consumes the resize flag to invalidate the diff cache if SIGWINCH fired, then renders when `consumeRerenderFlag()` or `appState.needsRender` is true. After rendering, it reads up to 128 non-blocking key events per frame and dispatches each through five handler layers. A `usleep(28_000)` throttles the loop to approximately 35 FPS. Asynchronous render triggers (timers, @State changes, SIGWINCH, focus changes) feed back into the render decision via `appState.needsRender` or `signals.requestRerender()`. - -@Image(source: "architecture-event-loop.png", alt: "Flowchart of the TUIkit event loop: @main entry initializes subsystems, sets up terminal, starts timers and observers, performs an initial render, then enters the main loop. The loop checks shouldShutdown, consumes the resize flag to invalidate the diff cache, checks rerenderFlag or needsRender to conditionally render, reads key events non-blocking up to 128 per frame, dispatches through 5 input layers, and sleeps 28ms. SIGINT exits to cleanup. Async render triggers from timers, state changes, SIGWINCH, and focus changes feed back into the needsRender check.") +`AppRunner` creates `InputHandler`, `RenderLoop`, and a +`RuntimeAnimationScheduler`; installs dispatch-backed signal and terminal-input +sources; registers state and focus observers; and performs an initial render. +`PulseTimer` and `CursorTimer` are monotonic phase calculators rather than +background timers. + +The runner then awaits `RuntimeEventChannel`. State invalidations request a +render, input readiness drains up to 128 key events, SIGWINCH invalidates the +diff cache, animation deadlines advance visible focus effects, and termination +events begin cleanup. These paths are serialized on the main actor. With no +pending event or visible animation deadline, the runtime remains suspended and +does no periodic work. + +Signal and input callbacks only enqueue events. Shutdown stops every event +source, finishes the channel, cancels view tasks through `TUIContext.reset()`, +and restores raw mode, cursor visibility, and the alternate screen before an +I/O failure is propagated. Input dispatch uses a first-consumer-wins model. Layer 0 and Layer 3 are mutually exclusive: when a text input element (TextField/SecureField) is focused, Layer 0 runs and Layer 3 is skipped; otherwise Layer 0 is skipped and Layer 3 runs. Both use `focusManager.dispatchKeyEvent()`, which first delegates to the focused element, then handles Tab/Shift+Tab navigation, then arrow key fallback. diff --git a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md index 3aa59bfb1..001972394 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/RenderCycle.md @@ -4,20 +4,24 @@ Understand how TUIkit turns your view tree into terminal output: one frame at a ## Overview -Every frame in TUIkit follows the same synchronous pipeline: **clear per-frame state → build environment → render the view tree → diff against previous frame → flush to terminal → track lifecycle**. The view tree is fully re-evaluated each frame, but only **changed terminal lines** are written: and all writes are collected in a frame buffer and flushed as a **single `write()` syscall**. +Every frame in TUIkit follows the same synchronous pipeline: **clear per-frame state → build environment → render the view tree → diff against previous frame → flush to terminal → track lifecycle**. The view tree is fully re-evaluated each frame, but only **changed terminal lines** are written. Those writes are collected in a frame buffer and normally flushed with one `write()` syscall. ## What Triggers a Frame -Several sources cause `RenderLoop` to produce a new frame. They converge on two boolean checks in the main loop (`consumeRerenderFlag()` and `appState.needsRender`): +Several sources cause `RenderLoop` to produce a new frame. They enter one +`RuntimeEventChannel`, and `AppRunner` consumes them serially on the main actor: | Trigger | Source | Mechanism | |---------|--------|-----------| -| Terminal resize | `SIGWINCH` signal | `SignalManager` sets `signalNeedsRerender` and `signalTerminalResized` | -| State mutation | `@State` property change | The owning runtime's invalidation sink notifies `AppState` | -| Animation timers | PulseTimer (100 ms) / CursorTimer (50 ms) | Calls `appState.setNeedsRender()` | -| Focus change | `FocusManager.onFocusChange` | Resets pulse timer and calls `appState.setNeedsRender()` | +| Terminal resize | `SIGWINCH` dispatch source | Sends `.terminalResized` and invalidates the diff cache | +| State mutation | `@State` property change | `AppState` sends `.renderRequested` through its observer | +| Focus animation | `RuntimeAnimationScheduler` | Sends `.animationDeadline` only while an animation is visible | +| View animation | Spinner or notification task | Invalidates `AppState`, which sends `.renderRequested` | +| Focus change | `FocusManager.onFocusChange` | Resets the pulse phase and invalidates `AppState` | -All triggers converge on boolean flags that the main loop checks each iteration. The actual rendering always happens on the main thread: signal handlers never render directly. +The event loop suspends when no event or animation deadline is pending. Signal +callbacks and background tasks only enqueue events or invalidations; rendering +itself stays serialized on the main actor. ## The Render Pipeline @@ -120,7 +124,10 @@ The status bar renders in a separate pass but writes into the **same frame buffe ### Step 11: Flush Frame -`Terminal.endFrame()` writes the entire collected buffer to `STDOUT_FILENO` in a **single `write()` syscall**, then resets the buffer. This reduces per-frame syscalls from ~40+ to exactly 1. +`Terminal.endFrame()` normally writes the entire collected buffer to +`STDOUT_FILENO` in a **single `write()` syscall**, then resets the buffer. +Interrupted calls and partial transfers are retried; permanent failures are +propagated after terminal cleanup. ### Step 12: End Lifecycle and State Tracking @@ -228,7 +235,7 @@ After the view tree produces a ``FrameBuffer``, the `FrameDiffWriter` prepares t 2. Each line is padded to full terminal width 3. Empty lines are filled with the background color -The diff writer then compares each output line with the previous frame. Only lines that actually changed are written to the terminal via `Terminal.moveCursor()` + `Terminal.write()`. All writes are collected in a frame buffer and flushed as a single syscall. +The diff writer then compares each output line with the previous frame. Only lines that actually changed are written to the terminal via `Terminal.moveCursor()` + `Terminal.write()`. All writes are collected in a frame buffer and normally flushed with one syscall. ## Environment Flow @@ -334,7 +341,10 @@ TUIkit uses three techniques to minimize terminal I/O: ### Frame Buffering -All terminal writes during a frame are collected in an internal `[UInt8]` buffer via `Terminal.beginFrame()` / `Terminal.endFrame()`. The entire frame is flushed to `STDOUT_FILENO` in a **single `write()` syscall**, reducing per-frame syscalls from ~40+ to exactly 1. +All terminal writes during a frame are collected in an internal `[UInt8]` +buffer via `Terminal.beginFrame()` / `Terminal.endFrame()`. The entire frame is +normally flushed to `STDOUT_FILENO` with one `write()` syscall. Interrupted and +partial transfers are retried without truncating the frame. ### Width Caching diff --git a/Sources/TUIkit/Views/Spinner.swift b/Sources/TUIkit/Views/Spinner.swift index 7fa27470f..210e3fb52 100644 --- a/Sources/TUIkit/Views/Spinner.swift +++ b/Sources/TUIkit/Views/Spinner.swift @@ -284,7 +284,7 @@ private struct _SpinnerCore: View, Renderable { if !lifecycle.hasAppeared(token: token) { _ = lifecycle.recordAppear(token: token) {} - let triggerNanos: UInt64 = 23_800_000 // ~24ms — matches run loop poll rate (~42 FPS) + let triggerNanos: UInt64 = 23_800_000 // ~42 animation frames per second lifecycle.startTask(token: token, priority: .medium) { [invalidationSink] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: triggerNanos) diff --git a/Sources/TUIkit/Views/_ImageCore.swift b/Sources/TUIkit/Views/_ImageCore.swift index a0da305cf..a37a30098 100644 --- a/Sources/TUIkit/Views/_ImageCore.swift +++ b/Sources/TUIkit/Views/_ImageCore.swift @@ -103,8 +103,7 @@ struct _ImageCore: View, Renderable, Layoutable { // Store the raw image; conversion happens per render pass. // StateBox.didSet triggers setNeedsRender() automatically. - // Do NOT use MainActor.run here: the render loop blocks the - // main actor with usleep, so MainActor.run would deadlock. + // The runtime delivers that invalidation to its async event loop. phaseBox.value = .success(rawImage) } catch let loadError as ImageLoadError { phaseBox.value = .failure(loadError.description) From 033c026a10ad6212273cb7749369815533aa6bfc Mon Sep 17 00:00:00 2001 From: phranck Date: Mon, 20 Jul 2026 01:17:34 +0200 Subject: [PATCH 8/8] Fix: Preserve the synchronous App entry point - Keep the public App.main symbol compatible with SwiftUI. - Run the asynchronous application runtime through dispatchMain. - Report runtime failures before returning a nonzero exit status. --- Sources/TUIkit/App/App.swift | 44 ++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/Sources/TUIkit/App/App.swift b/Sources/TUIkit/App/App.swift index 45ec53eae..85c7d3c16 100644 --- a/Sources/TUIkit/App/App.swift +++ b/Sources/TUIkit/App/App.swift @@ -12,6 +12,8 @@ import Darwin #endif +import Dispatch + // MARK: - App Protocol /// The base protocol for TUIkit applications. @@ -50,10 +52,44 @@ extension App { /// This method is called by the `@main` attribute and starts /// the main run loop of the application. /// - public static func main() async throws { - let app = Self() - let runner = AppRunner(app: app) - try await runner.run() + public static func main() { + _ = Task { @MainActor in + do { + let runner = AppRunner(app: Self()) + try await runner.run() + exit(EXIT_SUCCESS) + } catch { + writeApplicationFailure(error) + exit(EXIT_FAILURE) + } + } + dispatchMain() + } +} + +/// Writes a best-effort runtime failure diagnostic to standard error. +private func writeApplicationFailure(_ error: any Error) { + let bytes = Array("TUIkit application failed: \(error)\n".utf8) + let systemCalls = TerminalSystemCalls.system + + bytes.withUnsafeBufferPointer { buffer in + guard let baseAddress = buffer.baseAddress else { return } + var written = 0 + + while written < buffer.count { + let result = systemCalls.write( + STDERR_FILENO, + baseAddress + written, + buffer.count - written + ) + if result > 0 { + written += result + } else if result < 0, systemCalls.errorCode() == EINTR { + continue + } else { + return + } + } } }