Skip to content

Commit f18e6f5

Browse files
authored
Merge pull request #130 from Homebrew/fix-flakey-pty-test
Stop the pty tests racing the child they spawn
2 parents b810d75 + a58034e commit f18e6f5

3 files changed

Lines changed: 95 additions & 21 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//
2+
// AsyncTestExpectations.swift
3+
// BrewTests
4+
//
5+
6+
import Foundation
7+
import Testing
8+
9+
func waitUntil(
10+
_ comment: Comment? = nil,
11+
timeout: Duration = .seconds(5),
12+
poll: Duration = .milliseconds(10),
13+
sourceLocation: SourceLocation = #_sourceLocation,
14+
_ condition: @Sendable () async -> Bool,
15+
) async throws {
16+
let deadline = ContinuousClock.now + timeout
17+
while ContinuousClock.now < deadline {
18+
if await condition() {
19+
return
20+
}
21+
try await Task.sleep(for: poll)
22+
}
23+
guard await condition() else {
24+
Issue.record(comment ?? "Timed out after \(timeout)", sourceLocation: sourceLocation)
25+
return
26+
}
27+
}
28+
29+
actor TestGate {
30+
private var isOpen = false
31+
private var waiters: [CheckedContinuation<Void, Never>] = []
32+
33+
func wait() async {
34+
guard !isOpen else {
35+
return
36+
}
37+
await withCheckedContinuation { continuation in
38+
waiters.append(continuation)
39+
}
40+
}
41+
42+
func open() {
43+
isOpen = true
44+
let pending = waiters
45+
waiters = []
46+
for continuation in pending {
47+
continuation.resume()
48+
}
49+
}
50+
}

Tests/BrewCLITests/BrewCommandServicePseudoTerminalTests.swift

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ struct BrewCommandServicePseudoTerminalTests {
3838
lineObserver: { collector.append($0) },
3939
)
4040

41-
#expect(collector.allLines().map(\.text) == ["one", "two", "three"])
41+
#expect(collector.allLines().filter(\.isComplete).map(\.text) == ["one", "two", "three"])
4242
}
4343

4444
@Test func `pseudo-terminal run merges stderr into the stdout stream`() async throws {
@@ -123,12 +123,11 @@ struct BrewCommandServicePseudoTerminalTests {
123123
let collector = OutputCollector()
124124

125125
_ = try await run(
126-
script: "printf '10%%\\r'; sleep 0.1; printf '50%%\\r'; sleep 0.1; printf '100%%\\n'",
126+
script: "printf '10%%\\r'; sleep 0.3; printf '50%%\\r'; sleep 0.3; printf '100%%\\n'",
127127
channel: .pseudoTerminal,
128128
lineObserver: { collector.append($0) },
129129
)
130130

131-
// Separated by sleeps so they arrive as distinct reads.
132131
let revisions = collector.allLines().filter { !$0.isComplete }.map(\.text)
133132
#expect(revisions == ["10%", "50%"])
134133
}
@@ -171,13 +170,15 @@ struct BrewCommandServicePseudoTerminalTests {
171170
)
172171
}
173172

174-
try await Task.sleep(for: .milliseconds(500))
175-
let spawned = Self.grandchildCount()
173+
try await waitUntil("the child never spawned a grandchild", poll: .milliseconds(50)) {
174+
Self.grandchildCount() > 0
175+
}
176176
task.cancel()
177177
_ = await task.result
178-
try await Task.sleep(for: .milliseconds(1500))
179178

180-
#expect(spawned > 0 && Self.grandchildCount() == 0)
179+
try await waitUntil("the grandchild outlived the cancelled run", poll: .milliseconds(50)) {
180+
Self.grandchildCount() == 0
181+
}
181182
}
182183
}
183184

Tests/BrewCLITests/PseudoTerminalTests.swift

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,10 @@ struct PseudoTerminalTests {
4949
@Test func `read reports end of input once the child and the local replica are gone`() throws {
5050
let terminal = try PseudoTerminal()
5151
let process = try startProcess(script: "printf 'done\\n'", terminal: terminal)
52-
terminal.closeReplica()
53-
54-
let output = drainToEndOfInput(terminal)
55-
process.waitUntilExit()
56-
terminal.closePrimary()
5752

5853
// Terminating at all is the assertion: a leaked replica descriptor would hang this forever.
54+
let output = collectOutput(of: process, on: terminal)
55+
5956
#expect(output == "done\n")
6057
}
6158

@@ -96,40 +93,66 @@ struct PseudoTerminalTests {
9693
}
9794

9895
private extension PseudoTerminalTests {
99-
/// Runs `script` with the replica as stdout+stderr and drains the primary to end-of-input.
10096
func runOnPseudoTerminal(
10197
columns: UInt16 = PseudoTerminal.defaultColumns,
10298
rows: UInt16 = PseudoTerminal.defaultRows,
10399
script: String,
104100
) throws -> String {
105101
let terminal = try PseudoTerminal(columns: columns, rows: rows)
106102
let process = try startProcess(script: script, terminal: terminal)
107-
terminal.closeReplica()
103+
let output = collectOutput(of: process, on: terminal)
108104

109-
let output = drainToEndOfInput(terminal)
110-
process.waitUntilExit()
111-
terminal.closePrimary()
105+
#expect(process.terminationStatus == 0, "the child exited \(process.terminationStatus)")
112106

113107
return output
114108
}
115109

116-
/// Ignores idle timeouts; safe because every script under test terminates on its own.
117-
func drainToEndOfInput(_ terminal: PseudoTerminal) -> String {
110+
func collectOutput(of process: Process, on terminal: PseudoTerminal) -> String {
118111
var data = Data()
112+
113+
live: while true {
114+
switch terminal.read(timeout: .milliseconds(25)) {
115+
case let .data(chunk):
116+
data.append(chunk)
117+
case .timedOut:
118+
if !process.isRunning {
119+
break live
120+
}
121+
case .endOfInput:
122+
Issue.record("end of input arrived while this process still held the replica open")
123+
break live
124+
case let .failed(code):
125+
Issue.record("reading the terminal failed: \(PseudoTerminal.describe(errno: code))")
126+
break live
127+
}
128+
}
129+
process.waitUntilExit()
130+
131+
terminal.closeReplica()
132+
drainToEndOfInput(terminal, into: &data)
133+
terminal.closePrimary()
134+
135+
return UTF8StreamDecoder.lossyString(data)
136+
}
137+
138+
func drainToEndOfInput(_ terminal: PseudoTerminal, into data: inout Data) {
139+
let deadline = Date().addingTimeInterval(10)
119140
loop: while true {
120141
switch terminal.read() {
121142
case let .data(chunk):
122143
data.append(chunk)
123144
case .timedOut:
124-
continue
145+
if Date() > deadline {
146+
Issue.record("end of input never arrived: a replica descriptor is still open somewhere")
147+
break loop
148+
}
125149
case .endOfInput:
126150
break loop
127151
case let .failed(code):
128152
Issue.record("reading the terminal failed: \(PseudoTerminal.describe(errno: code))")
129153
break loop
130154
}
131155
}
132-
return UTF8StreamDecoder.lossyString(data)
133156
}
134157

135158
/// `Foundation.Process` deliberately, to exercise ``PseudoTerminal`` independently of the runner.

0 commit comments

Comments
 (0)