Skip to content

Commit 59e75b2

Browse files
committed
Add a synthetic test for metal failing to render a frame
1 parent 464df52 commit 59e75b2

3 files changed

Lines changed: 71 additions & 6 deletions

File tree

Sources/SwiftTerm/Apple/Metal/MetalTerminalRenderer.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,16 @@ final class MetalTerminalRenderer: NSObject, MTKViewDelegate {
302302
self.sampler = sampler
303303
self.terminalView = terminalView
304304
super.init()
305+
#if DEBUG
306+
// This fault models a command buffer that does not release the frame
307+
// permit. Every draw then uses the real semaphore refusal path.
308+
if ProcessInfo.processInfo.environment["SWIFTTERM_TEST_METAL_FRAME_PERMIT_HELD"] == "1" {
309+
_ = frameSemaphore.wait(timeout: .now())
310+
// A real command buffer retains this semaphore in its completion
311+
// handler. Keep the same lifetime when this fault is active.
312+
_ = Unmanaged.passRetained(frameSemaphore)
313+
}
314+
#endif
305315
}
306316

307317
deinit {

Tools/RenderBench/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# RenderBench
2+
3+
RenderBench puts a real SwiftTerm terminal view in a macOS window. It feeds
4+
deterministic terminal data without a shell or a PTY.
5+
6+
## Reproduce a stalled Metal renderer
7+
8+
Run this command:
9+
10+
```sh
11+
cd Tools/RenderBench
12+
swift run RenderBench --scenario stalled-frame --seconds 10
13+
```
14+
15+
The scenario uses a debug-only fault. It holds the Metal frame permit before
16+
the first draw. This state models a command buffer whose completion handler did
17+
not release the permit.
18+
19+
The terminal model continues to accept dense, colored output. Each output
20+
update requests a display. Each draw enters the semaphore refusal path and
21+
returns before it creates a command buffer. The pane stays black because no
22+
completion handler can consume `pendingRedraw`.
23+
24+
The process prints the number of accepted updates. The window must remain black
25+
for the full run. A renderer fix must let a later draw present the colored
26+
terminal data without a call to `setUseMetal(false)` and `setUseMetal(true)`.
27+
28+
The `stalled-frame` scenario requires a debug build. Other scenarios also
29+
support release builds.

Tools/RenderBench/Sources/RenderBench/main.swift

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@
77
// byte-identical input and their profiles are directly comparable.
88
//
99
// Usage:
10-
// RenderBench [--metal] [--seconds N] [--scenario dense|medium|scroll|arabic|arabic-line]
10+
// RenderBench [--metal] [--seconds N]
11+
// [--scenario dense|medium|scroll|arabic|arabic-line|stalled-frame]
1112
//
1213
// Scenarios:
1314
// dense every cell gets its own truecolor foreground and background
1415
// (vtebench dense_cells shape: many one-cell attribute runs)
1516
// medium a color change every 8 cells (longer runs, fewer segments)
1617
// scroll plain ASCII lines that scroll the screen
1718
// arabic scrolling Arabic words (exercises the BiDi shaping path)
19+
// stalled-frame
20+
// hold the Metal frame permit before the first draw, then feed
21+
// terminal output. The pane stays black while the model changes.
1822
//
1923
// Profile it with Instruments:
2024
// swift build -c release
@@ -40,15 +44,26 @@ while let argument = argIterator.next() {
4044
case "--scenario":
4145
scenario = argIterator.next() ?? scenario
4246
default:
43-
print("usage: RenderBench [--metal] [--seconds N] [--scenario dense|medium|scroll|arabic|arabic-line]")
47+
print("usage: RenderBench [--metal] [--seconds N] " +
48+
"[--scenario dense|medium|scroll|arabic|arabic-line|stalled-frame]")
4449
exit(1)
4550
}
4651
}
47-
guard ["dense", "medium", "scroll", "arabic", "arabic-line"].contains(scenario) else {
52+
guard ["dense", "medium", "scroll", "arabic", "arabic-line", "stalled-frame"].contains(scenario) else {
4853
print("unknown scenario: \(scenario)")
4954
exit(1)
5055
}
5156

57+
if scenario == "stalled-frame" {
58+
#if DEBUG
59+
useMetal = true
60+
setenv("SWIFTTERM_TEST_METAL_FRAME_PERMIT_HELD", "1", 1)
61+
#else
62+
print("stalled-frame requires a debug build")
63+
exit(1)
64+
#endif
65+
}
66+
5267
/// Deterministic generator so every run feeds identical bytes.
5368
struct SplitMix64 {
5469
var state: UInt64
@@ -96,6 +111,8 @@ final class FrameSource {
96111
return scrollChunk(cols: cols, rows: rows)
97112
case "arabic-line":
98113
return arabicLine()
114+
case "stalled-frame":
115+
return coloredFrame(cols: cols, rows: rows, runLength: 1)
99116
default:
100117
return arabicChunk(rows: rows)
101118
}
@@ -183,6 +200,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
183200
window.title = "RenderBench: \(scenario)\(useMetal ? " (Metal)" : " (CG)")"
184201
terminalView = TerminalView(frame: window.contentView!.bounds)
185202
terminalView.terminalDelegate = terminalViewDelegate
203+
window.contentView!.addSubview(terminalView)
204+
window.makeKeyAndOrderFront(nil)
205+
NSApp.activate(ignoringOtherApps: true)
206+
186207
if useMetal {
187208
do {
188209
try terminalView.setUseMetal(true)
@@ -191,13 +212,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
191212
exit(1)
192213
}
193214
}
194-
window.contentView!.addSubview(terminalView)
195-
window.makeKeyAndOrderFront(nil)
196-
NSApp.activate(ignoringOtherApps: true)
197215

198216
let t = terminalView.getTerminal()
199217
print("renderer=\(useMetal ? "metal" : "cg") scenario=\(scenario) " +
200218
"cols=\(t.cols) rows=\(t.rows) seconds=\(seconds)")
219+
if scenario == "stalled-frame" {
220+
print("FAULT ACTIVE: the Metal frame permit is held before the first draw.")
221+
print("EXPECTED: the pane stays black while the terminal accepts synthetic output.")
222+
}
201223
startTime = CFAbsoluteTimeGetCurrent()
202224
DispatchQueue.main.async { self.tick() }
203225
}
@@ -220,6 +242,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
220242
}
221243
if elapsed >= seconds {
222244
report(elapsed: elapsed, prefix: "TOTAL")
245+
if scenario == "stalled-frame" {
246+
print("REPRODUCED: the terminal accepted \(frameCount) updates, " +
247+
"but every Metal draw was refused.")
248+
}
223249
exit(0)
224250
}
225251
DispatchQueue.main.async { self.tick() }

0 commit comments

Comments
 (0)