diff --git a/Tests/SubprocessTests/IntegrationTests.swift b/Tests/SubprocessTests/IntegrationTests.swift index 0b180ffe..3c843af8 100644 --- a/Tests/SubprocessTests/IntegrationTests.swift +++ b/Tests/SubprocessTests/IntegrationTests.swift @@ -3691,6 +3691,88 @@ extension SubprocessIntegrationTests { #endif // SubprocessFoundation } +// MARK: - Resource Management Tests + +#if !os(Android) // Exit tests are not supported on Android +/// The number of open resources (file descriptors on POSIX, kernel handles on Windows) held by the current process. +private func openResourceCount() -> Int? { + #if os(Windows) + var count: DWORD = 0 + guard GetProcessHandleCount(GetCurrentProcess(), &count) else { + return nil + } + return Int(count) + #else + // Count open fds by probing each descriptor number up to the soft limit. + // This is portable across every POSIX target (Linux, Android, Darwin, the BSDs) + let limit = Int(min(_subprocess_nofile_soft_limit(), UInt64(4096))) + var count = 0 + for fd in 0.. Arguments { ["/c", "echo", "hello-\(i)"] } + #else + let executable: Executable = .name("echo") + func makeArguments(_ i: Int) -> Arguments { ["hello-\(i)"] } + #endif + + // Warm up once so lazily-created singletons (the process-monitor + // thread, AsyncIO's epoll/IOCP machinery, etc.) are allocated before + // we sample the baseline. + _ = try await Subprocess.run(executable, arguments: makeArguments(0), output: .string(limit: .max)) + guard let before = openResourceCount() else { + #if compiler(>=6.3) + try Test.cancel("Cannot determine the number of open resources") + #else + return + #endif + } + + let iterations = 500 + for i in 0..=6.3) + try Test.cancel("Cannot determine the number of open resources") + #else + return + #endif + } + let delta = after - before + + precondition(delta <= 0, "Subprocess leaked \(delta) resources while iterating \(iterations) times.") + } + } +} +#endif // !os(Android) + // MARK: - Utilities extension String { func trimmingNewLineAndQuotes() -> String { diff --git a/Tests/SubprocessTests/UnixTests.swift b/Tests/SubprocessTests/UnixTests.swift index a6032aa7..edfb0c20 100644 --- a/Tests/SubprocessTests/UnixTests.swift +++ b/Tests/SubprocessTests/UnixTests.swift @@ -934,66 +934,77 @@ extension FileDescriptor { // MARK: - Performance Tests extension SubprocessUnixTests { - #if SubprocessFoundation + #if SubprocessFoundation && !os(Android) @Test(.requiresBash) func testConcurrentRun() async throws { - // Read the soft fd limit via a C shim: RLIMIT_NOFILE's Swift type - // varies across platforms and Swift versions, so calling getrlimit - // directly from Swift is not reliably portable. - // Cap at 4096: Docker containers can report limits like 2^20. - let softLimit = Int(min(_subprocess_nofile_soft_limit(), UInt64(4096))) - - // On Linux, account for any fds already open (e.g. from prior tests in - // the same suite) to avoid hitting EMFILE during the concurrent spawn - // burst. /proc/self/fd lists every open descriptor; subtracting the - // current count plus a small margin gives the true available headroom. - #if os(Linux) || os(Android) - let currentFds = (try? FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd"))?.count ?? 50 - let available = max(32, softLimit - currentFds - 50) - #else - let available = softLimit - #endif - // Each concurrent spawn holds both ends of the stdout and stderr pipes - // plus a temporary exec-error notification pipe while the child's - // exec() completes — roughly 6–8 fds per in-flight spawn. Divide by - // 8 to leave headroom and avoid EMFILE under high concurrency. - let maxConcurrent = available / 8 - try await withThrowingTaskGroup(of: Void.self) { group in - var running = 0 - let byteCount = 1000 - for _ in 0..&2"#, "--", String(repeating: "X", count: byteCount), - ], - output: .data(limit: .max), - error: .data(limit: .max) - ) - guard r.terminationStatus.isSuccess else { - Issue.record("Unexpected exit \(r.terminationStatus) from \(r.processIdentifier)") - return + /// This test runs inside an exit test for two reasons: + /// + /// 1) Isolated process means no sibling test suites share its fd/handle table. That makes the + /// resource count deterministic and lets us assert a strict threshold + /// instead of relying on sampling/timing heuristics. + /// + /// 2) IODescriptor deinit now `fatalError`s if the descriptor is not closed. An exit test will + /// help us catch fd leaks without crashing the whole test suite. + await #expect(processExitsWith: .success) { + // Read the soft fd limit via a C shim: RLIMIT_NOFILE's Swift type + // varies across platforms and Swift versions, so calling getrlimit + // directly from Swift is not reliably portable. + // Cap at 4096: Docker containers can report limits like 2^20. + let softLimit = Int(min(_subprocess_nofile_soft_limit(), UInt64(4096))) + + // Account for the fds already open in this (now isolated) process + // so the concurrent burst stays within RLIMIT_NOFILE. /proc/self/fd + // lists every open descriptor; subtracting it plus a small margin + // gives the true available headroom. In the isolated child this + // count is stable, so the budget is reproducible run to run. + #if os(Linux) || os(Android) + let currentFds = (try? FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd"))?.count ?? 50 + let available = max(32, softLimit - currentFds - 50) + #else + let available = softLimit + #endif + // Each concurrent spawn holds both ends of the stdout and stderr pipes + // plus a temporary exec-error notification pipe while the child's + // exec() completes — roughly 6–8 fds per in-flight spawn. Divide by + // 8 to leave headroom and avoid EMFILE under high concurrency. + let maxConcurrent = available / 8 + try await withThrowingTaskGroup(of: Void.self) { group in + var running = 0 + let byteCount = 1000 + for _ in 0..&2"#, "--", String(repeating: "X", count: byteCount), + ], + output: .data(limit: .max), + error: .data(limit: .max) + ) + guard r.terminationStatus.isSuccess else { + Issue.record("Unexpected exit \(r.terminationStatus) from \(r.processIdentifier)") + return + } + #expect(r.standardOutput.count == byteCount + 1, "\(r.standardOutput)") + #expect(r.standardError.count == byteCount + 1, "\(r.standardError)") + } catch { + Issue.record("Subprocess.run threw: \(error)") } - #expect(r.standardOutput.count == byteCount + 1, "\(r.standardOutput)") - #expect(r.standardError.count == byteCount + 1, "\(r.standardError)") - } catch { - Issue.record("Subprocess.run threw: \(error)") + } + running += 1 + // Throttle to maxConcurrent/8 live subprocesses at a time + // (rather than /4) to reduce peak memory pressure on + // memory-constrained kernel-testing VMs (e.g. QEMU + 5.10). + if running >= maxConcurrent / 8 { + try await group.next() } } - running += 1 - // Throttle to maxConcurrent/8 live subprocesses at a time - // (rather than /4) to reduce peak memory pressure on - // memory-constrained kernel-testing VMs (e.g. QEMU + 5.10). - if running >= maxConcurrent / 8 { - try await group.next() - } + try await group.waitForAll() } - try await group.waitForAll() } } #endif