Skip to content

Commit 478cff7

Browse files
authored
Merge pull request #184 from LockInTime/fix/supervised-owner-eagain
fix(sdk): ignore retryable reads on the supervised owner pipe
2 parents bdd72b0 + 60f08a3 commit 478cff7

4 files changed

Lines changed: 41 additions & 4 deletions

File tree

apps/headless/Sources/HeadlessCLI/main.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ private struct HostLauncher {
142142
let count = withUnsafeMutableBytes(of: &byte) { buffer in
143143
read(STDIN_FILENO, buffer.baseAddress, 1)
144144
}
145-
if count >= 0 || errno != EINTR { break }
145+
if supervisedOwnerChannelClosed(readCount: count, errnoValue: errno) { break }
146146
} else if status < 0, errno != EINTR {
147147
break
148148
}

apps/headless/Sources/HeadlessProtocol/SupervisedHost.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import Darwin
66
import Glibc
77
#endif
88

9+
/// Owner-pipe reads that mean the SDK/CLI is gone. EOF and unexpected data
10+
/// close the host. `EINTR` / `EAGAIN` / `EWOULDBLOCK` must not, or a
11+
/// spurious DispatchSource wakeup kills a live supervised session.
12+
public func supervisedOwnerChannelClosed(readCount: Int, errnoValue: Int32) -> Bool {
13+
if readCount >= 0 { return true }
14+
return errnoValue != EINTR && errnoValue != EAGAIN && errnoValue != EWOULDBLOCK
15+
}
16+
917
public final class SupervisedHostOwnerMonitor: @unchecked Sendable {
1018
private let source: DispatchSourceRead
1119
private let lock = NSLock()
@@ -28,7 +36,7 @@ public final class SupervisedHostOwnerMonitor: @unchecked Sendable {
2836
let count = withUnsafeMutableBytes(of: &byte) { buffer in
2937
read(STDIN_FILENO, buffer.baseAddress, 1)
3038
}
31-
if count >= 0 || errno != EINTR {
39+
if supervisedOwnerChannelClosed(readCount: count, errnoValue: errno) {
3240
self.stop()
3341
onOwnerExit()
3442
}

apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2766,6 +2766,33 @@ struct ProtocolTests {
27662766
)
27672767
}
27682768

2769+
static func supervisedOwnerChannelIgnoresRetryableReads() throws {
2770+
try expect(
2771+
supervisedOwnerChannelClosed(readCount: 0, errnoValue: 0),
2772+
"EOF on the owner pipe must stop the host"
2773+
)
2774+
try expect(
2775+
supervisedOwnerChannelClosed(readCount: 1, errnoValue: 0),
2776+
"unexpected owner-pipe data must stop the host"
2777+
)
2778+
try expect(
2779+
!supervisedOwnerChannelClosed(readCount: -1, errnoValue: EINTR),
2780+
"EINTR must not be treated as owner exit"
2781+
)
2782+
try expect(
2783+
!supervisedOwnerChannelClosed(readCount: -1, errnoValue: EAGAIN),
2784+
"EAGAIN must not be treated as owner exit"
2785+
)
2786+
try expect(
2787+
!supervisedOwnerChannelClosed(readCount: -1, errnoValue: EWOULDBLOCK),
2788+
"EWOULDBLOCK must not be treated as owner exit"
2789+
)
2790+
try expect(
2791+
supervisedOwnerChannelClosed(readCount: -1, errnoValue: EBADF),
2792+
"a broken owner descriptor must stop the host"
2793+
)
2794+
}
2795+
27692796
static func oversizedSocketRequestIsRejected() throws {
27702797
try LocalRuntime.preparePrivateDirectory()
27712798
let socketPath = LocalRuntime.directoryURL
@@ -4115,6 +4142,7 @@ struct ProtocolTests {
41154142
("host authentication orchestration", hostAuthenticationOrchestration),
41164143
("docs command reference matches help", docsCommandReferenceMatchesHelp),
41174144
("menu shortcuts have unique chords", menuShortcutsHaveUniqueChords),
4145+
("supervised owner channel ignores retryable reads", supervisedOwnerChannelIgnoresRetryableReads),
41184146
("artifact file upload boundaries", artifactUploadCommands),
41194147
]
41204148

packages/headless-python/tests/test_lifecycle.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,8 +479,9 @@ async def scenario() -> None:
479479
HEADLESS_TEST_PID_FILE=str(pid_file),
480480
),
481481
)
482-
# Cleanup has its own 100 ms budget after the single 300 ms startup budget.
483-
assert time.monotonic() - started < 0.5
482+
# Cleanup has its own 100 ms budget after the single 300 ms startup
483+
# budget. Leave slack for slow macOS 3.14 CI runners.
484+
assert time.monotonic() - started < 1.0
484485

485486
asyncio.run(scenario())
486487
assert process_is_gone(int(pid_file.read_text()))

0 commit comments

Comments
 (0)