Skip to content

Commit d226499

Browse files
committed
Read subprocess stderr with a blocking loop, not readabilityHandler
`readabilityHandler` installs a libdispatch read source on the stderr pipe's file descriptor. Tearing that source down races epoll event delivery on Linux: `_dispatch_event_loop_drain` can dereference a `dispatch_muxnote` that a concurrent source teardown has already freed, crashing the process. This was observed intermittently in Linux CI while tearing down clangd connections and confirmed by core-dump analysis. Forward stderr with a blocking read loop on a dedicated queue instead. A blocking read never registers the file descriptor with libdispatch's event loop, so no read source is created for the pipe. Read the raw file descriptor rather than `FileHandle.read(upToCount:)`: on non-Darwin platforms the latter blocks until it accumulates the full requested count or reaches EOF, which would withhold small, intermittently emitted messages until the subprocess exits. A raw `read` returns as soon as any bytes are available. Extract the loop into `readInBackground` and add tests covering forwarding through end-of-file and prompt delivery of data before EOF.
1 parent 213ecb6 commit d226499

2 files changed

Lines changed: 134 additions & 8 deletions

File tree

Sources/LanguageServerProtocolTransport/JSONRPCConnection.swift

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,16 @@ public import LanguageServerProtocol
1616
import Synchronization
1717
@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions
1818

19-
#if canImport(Android)
19+
#if canImport(Darwin)
20+
import Darwin
21+
#elseif canImport(Glibc)
22+
import Glibc
23+
#elseif canImport(Musl)
24+
import Musl
25+
#elseif canImport(Android)
2026
import Android
27+
#elseif canImport(WinSDK)
28+
import WinSDK
2129
#endif
2230

2331
#if canImport(CDispatch)
@@ -187,13 +195,11 @@ public final class JSONRPCConnection: Connection {
187195
Logger(subsystem: LoggingScope.subsystem, category: stderrLoggingCategory).info("\($0)")
188196
}
189197
let stderrHandler = Pipe()
190-
stderrHandler.fileHandleForReading.readabilityHandler = { fileHandle in
191-
let newData = fileHandle.availableData
192-
if newData.count == 0 {
193-
stderrHandler.fileHandleForReading.readabilityHandler = nil
194-
} else {
195-
logForwarder.handleDataFromPipe(newData)
196-
}
198+
Self.readInBackground(
199+
fileHandle: stderrHandler.fileHandleForReading,
200+
queueLabel: "\(name)-stderr-read-queue"
201+
) { data in
202+
logForwarder.handleDataFromPipe(data)
197203
}
198204
process.standardError = stderrHandler
199205
process.terminationHandler = { process in
@@ -222,6 +228,54 @@ public final class JSONRPCConnection: Connection {
222228
}
223229
#endif // os(macOS) || !canImport(Darwin)
224230

231+
/// Read from `fileHandle` on a dedicated background queue with a blocking loop, forwarding each chunk to
232+
/// `receiveData` until end-of-file, then invoke `reachedEndOfFile`.
233+
///
234+
/// A blocking read is used rather than a `readabilityHandler`. `readabilityHandler` installs a libdispatch
235+
/// read source on the file descriptor; tearing that source down races epoll event delivery on Linux and
236+
/// can crash the process in `_dispatch_event_loop_drain`. A blocking read never registers the descriptor
237+
/// with libdispatch.
238+
///
239+
/// The raw file descriptor is read instead of `FileHandle.read(upToCount:)`: on non-Darwin platforms the
240+
/// latter blocks until it accumulates the full requested count or reaches EOF, which would withhold small,
241+
/// intermittently emitted output until the writer closes the pipe. A raw `read` returns as soon as any
242+
/// bytes are available.
243+
@_spi(Testing)
244+
public static func readInBackground(
245+
fileHandle: FileHandle,
246+
queueLabel: String,
247+
receiveData: @Sendable @escaping (Data) -> Void,
248+
reachedEndOfFile: (@Sendable () -> Void)? = nil
249+
) {
250+
let fileDescriptor = fileHandle.fileDescriptor
251+
let queue = DispatchQueue(label: queueLabel)
252+
queue.async {
253+
var buffer = [UInt8](repeating: 0, count: 8 * 1024)
254+
while true {
255+
let bytesRead = buffer.withUnsafeMutableBytes { ptr -> Int in
256+
#if os(Windows)
257+
return Int(_read(fileDescriptor, ptr.baseAddress, UInt32(ptr.count)))
258+
#else
259+
return read(fileDescriptor, ptr.baseAddress, ptr.count)
260+
#endif
261+
}
262+
if bytesRead > 0 {
263+
receiveData(Data(buffer[0..<bytesRead]))
264+
} else if bytesRead < 0 && errno == EINTR {
265+
// Interrupted by a signal; retry the read.
266+
continue
267+
} else {
268+
// Reached end-of-file (0) or hit an unrecoverable error; stop reading.
269+
break
270+
}
271+
}
272+
// Keep the handle alive for the duration of the loop so its file descriptor is not closed while
273+
// `read` is blocked on it.
274+
withExtendedLifetime(fileHandle) {}
275+
reachedEndOfFile?()
276+
}
277+
}
278+
225279
deinit {
226280
assert(state == .closed)
227281
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
@_spi(Testing) import LanguageServerProtocolTransport
14+
import Synchronization
15+
import ToolsProtocolsTestSupport
16+
import XCTest
17+
18+
import class Foundation.Pipe
19+
import struct Foundation.Data
20+
21+
final class PipeForwardingTests: XCTestCase {
22+
/// `readInBackground` forwards everything written to the pipe and signals end-of-file once the write end
23+
/// is closed.
24+
func testReadInBackgroundForwardsAllDataAndSignalsEndOfFile() async throws {
25+
let pipe = Pipe()
26+
let received = Mutex<Data>(Data())
27+
let reachedEndOfFile = expectation(description: "reached end of file")
28+
29+
JSONRPCConnection.readInBackground(
30+
fileHandle: pipe.fileHandleForReading,
31+
queueLabel: "test-read-in-background"
32+
) { data in
33+
received.withLock { $0.append(data) }
34+
} reachedEndOfFile: {
35+
reachedEndOfFile.fulfill()
36+
}
37+
38+
try pipe.fileHandleForWriting.write(contentsOf: Data("hello".utf8))
39+
try pipe.fileHandleForWriting.write(contentsOf: Data(" world".utf8))
40+
try pipe.fileHandleForWriting.close()
41+
42+
try await fulfillmentOfOrThrow(reachedEndOfFile)
43+
XCTAssertEqual(received.withLock { $0 }, Data("hello world".utf8))
44+
}
45+
46+
/// `readInBackground` delivers data as soon as it is available, before the pipe reaches end-of-file. A
47+
/// read that accumulated until its buffer filled or EOF (as `FileHandle.read(upToCount:)` does on
48+
/// non-Darwin platforms) would withhold this small chunk until the write end is closed.
49+
func testReadInBackgroundDeliversDataBeforeEndOfFile() async throws {
50+
let pipe = Pipe()
51+
let received = Mutex<Data>(Data())
52+
let receivedChunk = expectation(description: "received chunk before EOF")
53+
receivedChunk.assertForOverFulfill = false
54+
55+
JSONRPCConnection.readInBackground(
56+
fileHandle: pipe.fileHandleForReading,
57+
queueLabel: "test-read-in-background-prompt"
58+
) { data in
59+
let total = received.withLock { $0.append(data); return $0 }
60+
if total == Data("ping".utf8) {
61+
receivedChunk.fulfill()
62+
}
63+
}
64+
65+
// Do not close the write end: the chunk must be delivered without relying on EOF.
66+
try pipe.fileHandleForWriting.write(contentsOf: Data("ping".utf8))
67+
try await fulfillmentOfOrThrow(receivedChunk)
68+
69+
// Let the read loop terminate so its background thread does not outlive the test.
70+
try pipe.fileHandleForWriting.close()
71+
}
72+
}

0 commit comments

Comments
 (0)