Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a9d1e70
Add scripts for testing older Linux distros and kernels via qemu
cmcgee1024 May 30, 2026
9378aba
Code cleanup and fix various checks
cmcgee1024 May 30, 2026
6ac8247
Fix remaining license header check failure
cmcgee1024 May 30, 2026
05d9966
Fix remaining license header check failure
cmcgee1024 May 30, 2026
c33326b
Fix tests so that they work in a slow qemu environment
cmcgee1024 May 30, 2026
33bfdfd
Improved ergonomics of test script for interactive debugging
cmcgee1024 May 30, 2026
649cbb4
Unlock subprocess fork lock after failures in clone3
cmcgee1024 May 30, 2026
80d682a
Close the pidfd once epoll no longer references it
cmcgee1024 May 31, 2026
0943dca
Revert closing pidfds, adjust concurrency limit used for tests
cmcgee1024 May 31, 2026
683b38d
Better handling for epoll errors
cmcgee1024 May 31, 2026
b70141e
Bump memory for test VM
cmcgee1024 May 31, 2026
c540777
Gracefully handle epoll_ctl(DEL) failures, prevent single task failur…
cmcgee1024 May 31, 2026
6db447a
Add EMFILE and ENFILE to the ENOSYS fallback condition
cmcgee1024 Jun 1, 2026
c3c96e6
Revert change to catch EMFILE/ENFILE errors on pdfork
cmcgee1024 Jun 1, 2026
e086ce2
Fix two pidfd leak cases, and calculate available concurrency by read…
cmcgee1024 Jun 1, 2026
dcf84fa
Fix compile error for RLIMIT_NOFILE usage with FreeBSD
cmcgee1024 Jun 1, 2026
c603101
Fix compile error for RLIMIT_NOFILE usage with FreeBSD
cmcgee1024 Jun 1, 2026
f099368
Fix compile error for RLIMIT_NOFILE usage with FreeBSD
cmcgee1024 Jun 1, 2026
7eeb596
Fix occasional test hangs on FreeBSD
cmcgee1024 Jun 2, 2026
404067b
Fix FreeBSD hang while testing
cmcgee1024 Jun 2, 2026
d4cde49
Code review feedback
cmcgee1024 Jun 2, 2026
899b624
Revert converting defer close to closeAfter
cmcgee1024 Jun 2, 2026
8ff3fd4
Add detailed description for the continuation resume
cmcgee1024 Jun 2, 2026
cb5e9f4
Merge branch 'main' of https://github.com/swiftlang/swift-subprocess
cmcgee1024 Jun 3, 2026
fbfd28f
Revert changes to Thread
cmcgee1024 Jun 3, 2026
d542ed5
Revert vestigial BSD changes
cmcgee1024 Jun 3, 2026
3a3dcc7
Code review feedback
cmcgee1024 Jun 4, 2026
e18bbc1
Try without the no-parallel option for the qemu tests
cmcgee1024 Jun 4, 2026
49beeff
Revert process descriptor check
cmcgee1024 Jun 4, 2026
b9cb6cf
Code review feedback
cmcgee1024 Jun 5, 2026
346c6e7
Fix compile error
cmcgee1024 Jun 5, 2026
2a41bd4
Code review feedback
cmcgee1024 Jun 5, 2026
8499acd
Fix formatting
cmcgee1024 Jun 5, 2026
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
16 changes: 16 additions & 0 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ jobs:
done
# empty line to ignore the --swift-sdk given by swiftlang/github-workflows/.github/workflows/scripts/install-and-build-with-sdk.sh \

test_linux_kernel:
name: Test Linux Kernel / ${{ matrix.dist-kern }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# These are specific distro and kernel versions that the test qemu script supports from here: https://images.linuxcontainers.org
dist-kern: ["al2-4.18", "al2-5.10"]
container:
image: ubuntu:24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run Test
run: bash -c './scripts/test-using-qemu.sh ${{ matrix.dist-kern }} -- swift test'

soundness:
name: Soundness
uses: swiftlang/github-workflows/.github/workflows/soundness.yml@0.0.11
Expand Down
2 changes: 1 addition & 1 deletion .swift-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6.2.0
6.3.2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: what's our policy to update this file when new Swift is released? Do we always try to use the current release?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I end up deleting it in my working tree half the time because it causes problems when using a different version.

This is really meant for "apps" which build with one canonical version only, not libraries which build with multiple. [/rant]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In term of the policy of swift-subprocess and the swift version file, it's up to the code owners here what to do with it.

The swift version file is there to record that at least one person, and ideally the CI too, has verified that the project can be developed on a specific toolchain version with a degree of confidence that the package builds, tests will build and pass, the language server works, and things like code formatting work without extraneous diffs. The hope is to lessen "it works at my desk" kinds of problems with a measure of reproducibility, and to encourage new developers to a package because things just work right away.

By no means does this file indicate that this is the only version of the toolchain that can be used with this package, or the only one it supports. This is just the default that gets developers working with it to a known good configuration for most things and swiftly can help them with that.

Also, it has no effect on dependent packages. This is for development of this package, even if the package is a library package like this one.

It's worth noting that this file instructs what toolchain version to use for testing with the Linux kernel versions added as GH workflows in this PR as it stands. The Qemu setup script uses of swiftly to install the swift toolchain into the Linux VM that normally won't have it. It's currently configured to use this file as it's visible, easy to update, and swiftly can use it.

Please let me know if you'd prefer the test script to always use "latest", or something that's statically coded into the test script, or the GH workflow. I think that this file is simpler, more visible, and more reproducible than those alternatives.

31 changes: 26 additions & 5 deletions Sources/Subprocess/Platforms/Subprocess+Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -447,17 +447,37 @@ private func _unregisterProcessDescriptorAndNotify(_ pidfd: CInt, context: Monit
newStorage.continuations.removeValue(forKey: pidfd)
state = .started(newStorage)

// Remove this pidfd from epoll to prevent further notifications
let rc = epoll_ctl(
// Remove this pidfd from epoll to prevent further notifications.
// The return value is intentionally not propagated to the continuation:
// epoll firing this event means the process has already exited, so
// monitoring succeeded regardless of cleanup outcome.
//
// ENOENT is silently ignored: it means the fd is not (or is no longer)
// in the epoll instance, which is harmless — this occurs on concurrent
// removals and on older 5.x kernels where epoll_ctl(DEL) incorrectly
// reports ENOENT for pidfds after process exit. The fd is removed from
// epoll automatically by the kernel when processIdentifier.close() closes
// it anyway, so a failed DEL is never permanent.
//
// Any other error (EBADF, EINVAL, …) would indicate a programming error
// in fd lifecycle management — e.g. the pidfd was closed prematurely —
// and is surfaced as an assertion failure in debug builds.
let delRC = epoll_ctl(
context.epollFileDescriptor,
EPOLL_CTL_DEL,
pidfd,
nil
)
if rc != 0 {
let epollErrno = errno

// The pidfd is intentionally left open here. It is owned by
// ProcessIdentifier and will be closed by processIdentifier.close()
// in the defer in Configuration.swift once monitoring is fully done.
// Closing it here would free the fd number and allow it to be recycled
// before that defer runs, causing a close-the-wrong-fd race.

if delRC != 0 && errno != ENOENT {
let error = SubprocessError.failedToMonitor(
withUnderlyingError: Errno(rawValue: epollErrno)
withUnderlyingError: Errno(rawValue: errno)
)
return (continuationList, error)
}
Expand Down Expand Up @@ -552,6 +572,7 @@ internal func _isWaitprocessDescriptorSupported() -> Bool {
// If we can not retrieve pidfd, the system does not support waitid(P_PIDFD)
return false
}
defer { try? FileDescriptor(rawValue: selfPidfd).close() }
/// The following call will fail either with
/// - ECHILD: in this case we know P_PIDFD is supported and waitid correctly
/// reported that we don't have a child with the same selfPidfd;
Expand Down
10 changes: 10 additions & 0 deletions Sources/Subprocess/Platforms/Subprocess+Unix.swift
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,16 @@ extension Configuration {
// Spawn error
if spawnError != 0 {
if [ENOENT, EACCES, ENOTDIR].contains(spawnError) {
// clone3(CLONE_PIDFD) allocates a pidfd before exec runs.
// If exec fails we retry with the next candidate path, so
// close the pidfd here to avoid leaking it across retries.
if processDescriptor != .invalidDescriptor {
do {
try FileDescriptor(rawValue: processDescriptor).close()
} catch {
throw SubprocessError.spawnFailed(withUnderlyingError: error as? SubprocessError.UnderlyingError)
}
}
// Move on to another possible path
continue
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/_SubprocessCShims/include/process_shims.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#if !TARGET_OS_WINDOWS
#include <pthread.h>
#include <unistd.h>
#include <sys/resource.h>

#if _POSIX_SPAWN
#include <spawn.h>
Expand Down Expand Up @@ -95,6 +96,11 @@ int _was_process_signaled(int status);
int _get_signal_code(int status);
int _was_process_suspended(int status);

/// Returns the soft RLIMIT_NOFILE value for the current process, or 0 on
/// error. Implemented in C so that RLIMIT_NOFILE always resolves to the
/// correct type regardless of how the Swift Glibc/Darwin overlay imports it.
uint64_t _subprocess_nofile_soft_limit(void);

void _subprocess_lock_environ(void);
void _subprocess_unlock_environ(void);
char * _Nullable * _Nullable _subprocess_get_environ(void);
Expand Down
12 changes: 12 additions & 0 deletions Sources/_SubprocessCShims/process_shims.c
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ int _was_process_suspended(int status) {
return WIFSTOPPED(status);
}

uint64_t _subprocess_nofile_soft_limit(void) {
struct rlimit rl;
if (getrlimit(RLIMIT_NOFILE, &rl) != 0) {
return 0;
}
return (uint64_t)rl.rlim_cur;
}

int _subprocess_pthread_create(
pthread_t * _Nonnull ptr,
pthread_attr_t const * _Nullable attr,
Expand Down Expand Up @@ -584,6 +592,7 @@ int _subprocess_fork_exec(
if (rc != 0) {
close(pipefd[0]);
close(pipefd[1]);
pthread_mutex_unlock(&_subprocess_fork_lock);
return errno;
}

Expand All @@ -602,6 +611,7 @@ int _subprocess_fork_exec(
// Report all other errors
close(pipefd[0]);
close(pipefd[1]);
pthread_mutex_unlock(&_subprocess_fork_lock);
return errno;
}
}
Expand All @@ -610,6 +620,7 @@ int _subprocess_fork_exec(
// Fork failed
close(pipefd[0]);
close(pipefd[1]);
pthread_mutex_unlock(&_subprocess_fork_lock);
return errno;
}

Expand Down Expand Up @@ -765,6 +776,7 @@ int _subprocess_fork_exec(
// Restore old signmask
rc = pthread_sigmask(SIG_SETMASK, &old_sigmask, NULL);
if (rc != 0) {
pthread_mutex_unlock(&_subprocess_fork_lock);
reap_child_process_and_return_errno;
}

Expand Down
84 changes: 46 additions & 38 deletions Tests/SubprocessTests/UnixTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -895,52 +895,60 @@ extension FileDescriptor {
extension SubprocessUnixTests {
#if SubprocessFoundation
@Test(.requiresBash) func testConcurrentRun() async throws {
// Launch as many processes as we can
// Figure out the max open file limit
let limitResult = try await Subprocess.run(
.path("/bin/sh"),
arguments: ["-c", "ulimit -n"],
output: .string(limit: 32)
)
guard
let limitString = limitResult
.standardOutput?
.trimmingCharacters(in: .whitespacesAndNewlines),
let ulimit = Int(limitString)
else {
Issue.record("Failed to run ulimit -n")
return
}
// Constrain to an ultimate upper limit of 4096, since Docker containers can have limits like 2^20 which is a bit too high for this test.
// Common defaults are 2560 for macOS and 1024 for Linux.
let limit = min(ulimit, 4096)
// Since we open two pipes per `run`, launch
// limit / 4 subprocesses should reveal any
// file descriptor leaks
let maxConcurrent = limit / 4
// 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 {
// 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
// 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)")
}
running += 1
if running >= maxConcurrent / 4 {
// 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()
}
}
Expand Down
76 changes: 76 additions & 0 deletions scripts/prep-linux-swift.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/bin/bash
##===----------------------------------------------------------------------===##
##
## This source file is part of the Swift.org open source project
##
## Copyright (c) 2026 Apple Inc. and the Swift project authors
## Licensed under Apache License v2.0 with Runtime Library Exception
##
## See https://swift.org/LICENSE.txt for license information
##
##===----------------------------------------------------------------------===##

# This script does a bit of extra preparation of the docker containers used to run the GitHub workflows
# that are specific to this project's needs when building/testing. Note that this script runs on
# every supported Linux distribution so it must adapt to the distribution that it is running.

if [[ "$(uname -s)" == "Linux" ]]; then
# Install the basic utilities depending on the type of Linux distribution
apt-get --help && apt-get update && TZ=Etc/UTC apt-get -y install curl make gpg tzdata
yum --help && (curl --help && yum -y install curl) && yum -y install make gpg tar procps
fi

set -e

while [ $# -ne 0 ]; do
arg="$1"
case "$arg" in
--install-swiftly)
installSwiftly=true
;;
--swift-snapshot)
swiftSnapshot="$2"
shift;
;;
*)
;;
esac
shift
done

if [ "$installSwiftly" == true ]; then
echo "Installing swiftly"

curl -O "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" && tar zxf swiftly-*.tar.gz && ./swiftly init -y --skip-install
# shellcheck source=/dev/null
. "/root/.local/share/swiftly/env.sh"

hash -r

selector=()
runSelector=()

if [ "$swiftSnapshot" != "" ]; then
echo "Installing latest $swiftSnapshot-snapshot toolchain"
selector=("$swiftSnapshot-snapshot")
runSelector=("+$swiftSnapshot-snapshot")
elif [ -f .swift-version ]; then
echo "Installing selected swift toolchain from .swift-version file"
selector=()
runSelector=()
else
echo "Installing latest toolchain"
selector=("latest")
runSelector=("+latest")
fi

TMPDIR=/var/tmp swiftly install --post-install-file=post-install.sh "${selector[@]}"

if [ -f post-install.sh ]; then
echo "Performing swift toolchain post-installation"
chmod u+x post-install.sh && ./post-install.sh
fi

echo "Displaying swift version"
swiftly run "${runSelector[@]}" swift --version
fi
Loading
Loading