206 lines · 7.8 KB · Swift
RawDownload
import Foundation#if os(Linux)import Glibc#endifimport 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 readFileDescriptor = pipe.fileHandleForReading.fileDescriptor        let capture = ProcessPipeCapture(pipe: pipe)        capture.start(linuxDescriptorSetup: { descriptor in            errno = EMFILE            return descriptor < 0        })         let startedAt = ContinuousClock.now        let data = capture.finishSynchronously(timeout: 5)        let elapsed = startedAt.duration(to: .now)         #expect(data.isEmpty)        #expect(elapsed < .milliseconds(500))        #expect(Glibc.fcntl(readFileDescriptor, F_GETFD) == -1)        #expect(errno == EBADF)        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