213 lines
8.1 KiB
Swift
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
|