Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions Tests/SubprocessTests/IntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<limit where fcntl(CInt(fd), F_GETFD) != -1 {
count += 1
}
return count
#endif
}

extension SubprocessIntegrationTests {
/// Run many subprocesses sequentially to make sure Subprocess does not leak fds.
///
/// 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.
@Test func testManySequentialRunsDoNotLeakResources() async throws {
await #expect(processExitsWith: .success) {
#if os(Windows)
let executable: Executable = .name("cmd.exe")
func makeArguments(_ i: Int) -> 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..<iterations {
let result = try await Subprocess.run(
executable,
arguments: makeArguments(i),
output: .string(limit: .max)
)
precondition(result.terminationStatus.isSuccess)
precondition(result.standardOutput?.contains("hello-\(i)") == true)
}

guard let after = openResourceCount() else {
#if compiler(>=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 {
Expand Down
121 changes: 66 additions & 55 deletions Tests/SubprocessTests/UnixTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<maxConcurrent {
group.addTask {
// Catch errors so a single spawn/monitor failure doesn't
// cascade-cancel sibling tasks (which would SIGKILL their
// live subprocesses and flood the log with false failures).
do {
// This invocation specifically requires bash semantics; sh (on FreeBSD at least) does not consistently support -s in this way
let r = try await Subprocess.run(
.name("bash"),
arguments: [
"-sc", #"echo "$1" && echo "$1" >&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..<maxConcurrent {
group.addTask {
// Catch errors so a single spawn/monitor failure doesn't
// cascade-cancel sibling tasks (which would SIGKILL their
// live subprocesses and flood the log with false failures).
do {
// This invocation specifically requires bash semantics; sh (on FreeBSD at least) does not consistently support -s in this way
let r = try await Subprocess.run(
.name("bash"),
arguments: [
"-sc", #"echo "$1" && echo "$1" >&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
Expand Down
Loading