Files
steipete--codexbar/TestsLinux/ProcessPipeCaptureLinuxTests.swift
2026-08-13 03:40:38 -07:00

213 lines
8.1 KiB
Swift

import Foundation
#if os(Linux)
import Glibc
#endif
import Testing
@testable import CodexBarCore
#if os(Linux)
@Suite(.serialized)
struct ProcessPipeCaptureLinuxTests {
private static let emfileChildEnvironmentKey = "CODEXBAR_PROCESS_PIPE_EMFILE_CHILD"
@Test
func `blocked onData callback does not block capture close`() throws {
let callbackStarted = DispatchSemaphore(value: 0)
let releaseCallback = DispatchSemaphore(value: 0)
let captureFinished = DispatchSemaphore(value: 0)
let pipe = Pipe()
let capture = ProcessPipeCapture(pipe: pipe, onData: {
callbackStarted.signal()
releaseCallback.wait()
})
capture.start()
try pipe.fileHandleForWriting.write(contentsOf: Data("hello".utf8))
#expect(callbackStarted.wait(timeout: .now() + 1) == .success)
DispatchQueue.global().async {
_ = capture.finishSynchronously(timeout: 0.05)
captureFinished.signal()
}
let finishResult = captureFinished.wait(timeout: .now() + 0.5)
releaseCallback.signal()
#expect(finishResult == .success)
if finishResult != .success {
_ = captureFinished.wait(timeout: .now() + 1)
}
try pipe.fileHandleForWriting.close()
}
@Test
func `continuous output does not defeat the capture timeout`() throws {
let writerStarted = DispatchSemaphore(value: 0)
let stopWriter = DispatchSemaphore(value: 0)
let writerFinished = DispatchSemaphore(value: 0)
let pipe = Pipe()
let writerDescriptor = pipe.fileHandleForWriting.fileDescriptor
let writerFlags = Glibc.fcntl(writerDescriptor, F_GETFL)
#expect(writerFlags >= 0)
#expect(Glibc.fcntl(writerDescriptor, F_SETFL, writerFlags | O_NONBLOCK) == 0)
let capture = ProcessPipeCapture(pipe: pipe, maxBytes: 1024)
capture.start()
DispatchQueue.global().async {
var blockedSignals = sigset_t()
var previousSignals = sigset_t()
Glibc.sigemptyset(&blockedSignals)
Glibc.sigaddset(&blockedSignals, SIGPIPE)
_ = Glibc.pthread_sigmask(SIG_BLOCK, &blockedSignals, &previousSignals)
defer {
var pendingSignals = sigset_t()
if Glibc.sigpending(&pendingSignals) == 0, Glibc.sigismember(&pendingSignals, SIGPIPE) == 1 {
var noWait = timespec(tv_sec: 0, tv_nsec: 0)
_ = Glibc.sigtimedwait(&blockedSignals, nil, &noWait)
}
_ = Glibc.pthread_sigmask(SIG_SETMASK, &previousSignals, nil)
}
var bytes = [UInt8](repeating: 0x41, count: 16 * 1024)
while stopWriter.wait(timeout: .now()) == .timedOut {
let count = bytes.withUnsafeMutableBytes { buffer in
Glibc.write(writerDescriptor, buffer.baseAddress, buffer.count)
}
if count < 0, errno == EPIPE {
break
}
if count > 0 {
writerStarted.signal()
}
}
writerFinished.signal()
}
#expect(writerStarted.wait(timeout: .now() + 1) == .success)
let startedAt = ContinuousClock.now
_ = capture.finishSynchronously(timeout: 0.01)
let elapsed = startedAt.duration(to: .now)
stopWriter.signal()
#expect(elapsed < .milliseconds(500))
#expect(writerFinished.wait(timeout: .now() + 1) == .success)
try pipe.fileHandleForWriting.close()
}
@Test
func `Linux descriptor setup failure closes the read end immediately`() throws {
let pipe = Pipe()
let capture = ProcessPipeCapture(pipe: pipe)
capture.start(linuxDescriptorSetup: { descriptor in
errno = EMFILE
return descriptor < 0
})
// Descriptor numbers can be reused as soon as close returns. POLLERR on this pipe's write end proves
// the original pipe has no remaining reader without assuming its former descriptor stays unused.
var writerPollDescriptor = pollfd(
fd: pipe.fileHandleForWriting.fileDescriptor,
events: 0,
revents: 0)
#expect(Glibc.poll(&writerPollDescriptor, 1, 0) == 1)
#expect(writerPollDescriptor.revents & Int16(POLLERR) != 0)
let startedAt = ContinuousClock.now
let data = capture.finishSynchronously(timeout: 5)
let elapsed = startedAt.duration(to: .now)
#expect(data.isEmpty)
#expect(elapsed < .milliseconds(500))
try pipe.fileHandleForWriting.close()
}
@Test
func `capture starts while the process is at EMFILE`() throws {
if ProcessInfo.processInfo.environment[Self.emfileChildEnvironmentKey] == "1" {
try Self.runEMFILEChildScenario()
return
}
let process = Process()
let testExecutable = try FileManager.default.destinationOfSymbolicLink(atPath: "/proc/self/exe")
process.executableURL = URL(fileURLWithPath: testExecutable)
process.arguments = ["--filter", "ProcessPipeCaptureLinuxTests", "--testing-library", "swift-testing"]
var environment = ProcessInfo.processInfo.environment
environment[Self.emfileChildEnvironmentKey] = "1"
process.environment = environment
try process.run()
process.waitUntilExit()
#expect(process.terminationReason == .exit)
#expect(process.terminationStatus == 0)
}
@Test
func `ProcessPipeCapture releases its pipe read end after capture`() throws {
let initialFDs = try countOpenFDs()
for _ in 0..<100 {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/echo")
proc.arguments = ["hello"]
let out = Pipe()
proc.standardOutput = out
proc.standardError = FileHandle.nullDevice
let capture = ProcessPipeCapture(pipe: out)
capture.start()
try proc.run()
try out.fileHandleForWriting.close()
proc.waitUntilExit()
let data = capture.finishSynchronously(timeout: 0.25)
#expect(String(decoding: data, as: UTF8.self) == "hello\n")
}
let finalFDs = try countOpenFDs()
// Allow a small tolerance for unrelated fd churn, but ensure we are
// not leaking pipe read ends (which would show as ~100 extra fds).
#expect(finalFDs - initialFDs <= 15)
}
private static func runEMFILEChildScenario() throws {
var originalLimit = rlimit()
let noFileResource = Int32(RLIMIT_NOFILE.rawValue)
#expect(Glibc.getrlimit(noFileResource, &originalLimit) == 0)
let pipe = Pipe()
let capture = ProcessPipeCapture(pipe: pipe)
let highestOpenFileDescriptor = try FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd")
.compactMap(Int.init)
.max() ?? 32
var constrainedLimit = originalLimit
constrainedLimit.rlim_cur = min(originalLimit.rlim_cur, rlim_t(highestOpenFileDescriptor + 32))
#expect(Glibc.setrlimit(noFileResource, &constrainedLimit) == 0)
var heldFileDescriptors: [Int32] = []
defer {
for descriptor in heldFileDescriptors {
Glibc.close(descriptor)
}
_ = Glibc.setrlimit(noFileResource, &originalLimit)
}
while true {
let descriptor = Glibc.dup(STDIN_FILENO)
if descriptor < 0 {
#expect(errno == EMFILE)
break
}
heldFileDescriptors.append(descriptor)
}
capture.start()
try pipe.fileHandleForWriting.write(contentsOf: Data("hello".utf8))
try pipe.fileHandleForWriting.close()
let data = capture.finishSynchronously(timeout: 1)
#expect(String(decoding: data, as: UTF8.self) == "hello")
#expect(capture.reachedEOF)
}
}
private func countOpenFDs() throws -> Int {
let entries = try FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd")
return entries.count
}
#endif