|
| 1 | +# Adopting the render loop |
| 2 | + |
| 3 | +What changes for an application moving to the branch where SwiftTerm prepares |
| 4 | +and draws frames off the main thread. |
| 5 | + |
| 6 | +## Overview |
| 7 | + |
| 8 | +On macOS, a terminal using the GPU renderer now prepares and submits its frames |
| 9 | +on a dedicated render thread, driven by the display link. The main thread |
| 10 | +captures view state, signals the loop, and applies the AppKit side effects the |
| 11 | +loop hands back. Main-thread stall p99 under a flood drops from roughly 7–19 ms |
| 12 | +to 0.03–0.12 ms. |
| 13 | + |
| 14 | +**No API was removed and nothing became illegal.** Most applications need to |
| 15 | +change nothing. The list below is what is worth checking, in the order it is |
| 16 | +likely to matter. |
| 17 | + |
| 18 | +## 1. `setNeedsDisplay` does not force a terminal repaint |
| 19 | + |
| 20 | +If your code calls `setNeedsDisplay` on the terminal view to refresh it after |
| 21 | +changing terminal state directly — a soft reset, a palette swap, a renderer |
| 22 | +toggle — **that call does nothing.** `TerminalView.draw(_:)` returns |
| 23 | +immediately whenever a GPU renderer is attached, and frames come from the frame |
| 24 | +driver instead. |
| 25 | + |
| 26 | +This was already true whenever Metal was enabled. It matters more now because |
| 27 | +Metal is the default on macOS. |
| 28 | + |
| 29 | +```swift |
| 30 | +// Before: silently does nothing under a GPU renderer. |
| 31 | +terminal.getTerminal().softReset() |
| 32 | +terminal.setNeedsDisplay(terminal.bounds) |
| 33 | + |
| 34 | +// After: |
| 35 | +terminal.getTerminal().softReset() |
| 36 | +terminal.requestRedraw() |
| 37 | +``` |
| 38 | + |
| 39 | +``TerminalView/requestRedraw()`` is safe from any thread. Output fed through |
| 40 | +``TerminalView/feed(byteArray:)`` never needs it — that path marks the frame |
| 41 | +itself. |
| 42 | + |
| 43 | +## 2. Metal is on by default, on a new surface |
| 44 | + |
| 45 | +``TerminalView/setUseMetal(_:)`` is unchanged. What changed is what you get: |
| 46 | +a `CAMetalLayer` this view owns, drawn from the render loop, rather than an |
| 47 | +`MTKView` drawn from AppKit's display cycle. |
| 48 | + |
| 49 | +- ``TerminalView/usesMetalLayerSurface`` and |
| 50 | + ``TerminalView/setUsesMetalLayerSurface(_:)`` select the surface at runtime. |
| 51 | + Setting it while Metal is running rebuilds the surface in place. |
| 52 | +- ``TerminalView/isUsingRenderLoop`` reports whether frames are actually being |
| 53 | + prepared off the main thread. |
| 54 | +- `SWIFTTERM_METAL_LAYER=0` in the environment starts on the old `MTKView` |
| 55 | + surface. That is the rollback if you hit something; please report it. |
| 56 | + |
| 57 | +`MTKView` remains the only surface on iOS. |
| 58 | + |
| 59 | +## 3. You can call `send` and `feed` from any thread |
| 60 | + |
| 61 | +``TerminalView/send(data:)`` used to assert it was on the main thread. It no |
| 62 | +longer does: it takes the terminal lock around the state it mutates. An SSH or |
| 63 | +agent transport can call it directly. |
| 64 | + |
| 65 | +If you marshal input to the main thread only to satisfy that old contract, you |
| 66 | +can stop. |
| 67 | + |
| 68 | +The one rule: do not call it from inside a terminal delegate callback. Those |
| 69 | +run with the lock held, and a precondition catches the mistake rather than |
| 70 | +deadlocking. |
| 71 | + |
| 72 | +## 4. `sizeChanged` may arrive a frame later than the resize |
| 73 | + |
| 74 | +During a live window drag, SwiftTerm coalesces resizes to one per display |
| 75 | +frame. Your ``TerminalViewDelegate/sizeChanged(source:newCols:newRows:)`` is |
| 76 | +therefore called after the drag step that caused it, not inside it. |
| 77 | + |
| 78 | +That breaks the re-entrancy guard applications write when they resize a window |
| 79 | +from this callback: |
| 80 | + |
| 81 | +```swift |
| 82 | +// Fragile: `changingSize` is already false when the callback arrives. |
| 83 | +if changingSize { return } |
| 84 | +changingSize = true |
| 85 | +window.setFrame(optimal, display: true, animate: true) |
| 86 | +changingSize = false |
| 87 | +``` |
| 88 | + |
| 89 | +Compare frames instead of using a flag, and do not animate the resize — |
| 90 | +animating it is a measurable performance problem in its own right. <doc:Embedding> |
| 91 | +has the numbers and a correct implementation. |
| 92 | + |
| 93 | +Applications that do not resize a window from `sizeChanged` — anything with a |
| 94 | +fixed layout, or SwiftUI-driven sizing — are unaffected. |
| 95 | + |
| 96 | +## 5. Delegate callbacks and your own threads |
| 97 | + |
| 98 | +Unchanged, but easier to get wrong now that more of SwiftTerm is concurrent: |
| 99 | +terminal delegate methods can fire on the parse thread with the terminal lock |
| 100 | +held. Capture what you need and hop to the main queue yourself. Do not call |
| 101 | +back into SwiftTerm APIs that take the lock. |
| 102 | + |
| 103 | +## 6. What you get for free |
| 104 | + |
| 105 | +No action needed for any of these: |
| 106 | + |
| 107 | +- Frames continue while the main thread is busy. |
| 108 | +- Input-to-glyph latency roughly halved; the old 150 ms post-input special case |
| 109 | + is gone. |
| 110 | +- The terminal stops drawing entirely when occluded, miniaturised, or the |
| 111 | + application is hidden. |
| 112 | +- The synchronized-output safety valve fires even while the main thread is |
| 113 | + blocked, so an application that sets DECSET 2026 and never clears it can no |
| 114 | + longer wedge the display. |
| 115 | + |
| 116 | +## Topics |
| 117 | + |
| 118 | +### Related |
| 119 | + |
| 120 | +- <doc:Embedding> |
| 121 | +- <doc:GPURendering> |
| 122 | +- ``TerminalView/requestRedraw()`` |
| 123 | +- ``TerminalView/setUsesMetalLayerSurface(_:)`` |
0 commit comments