Skip to content

Commit 6b0d119

Browse files
authored
Test old Linux kernels using Qemu (#280)
Fixes the following: * Increases test confidence with older Linux kernels (4.18, and 5.10) * Closes file handles that were leaking on Linux * Better soft limit detection so that testConcurrentRun() can avoid exhausting system open file limit * Handle the epoll(DEL) case when a process is being shut down for Linux with older kernels
1 parent 4a6012f commit 6b0d119

9 files changed

Lines changed: 1116 additions & 44 deletions

File tree

.github/workflows/pull_request.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,22 @@ jobs:
5858
done
5959
# empty line to ignore the --swift-sdk given by swiftlang/github-workflows/.github/workflows/scripts/install-and-build-with-sdk.sh \
6060
61+
test_linux_kernel:
62+
name: Test Linux Kernel / ${{ matrix.dist-kern }}
63+
runs-on: ubuntu-latest
64+
strategy:
65+
fail-fast: false
66+
matrix:
67+
# These are specific distro and kernel versions that the test qemu script supports from here: https://images.linuxcontainers.org
68+
dist-kern: ["al2-4.18", "al2-5.10"]
69+
container:
70+
image: ubuntu:24.04
71+
steps:
72+
- name: Checkout repository
73+
uses: actions/checkout@v4
74+
- name: Run Test
75+
run: bash -c './scripts/test-using-qemu.sh ${{ matrix.dist-kern }} -- swift test'
76+
6177
soundness:
6278
name: Soundness
6379
uses: swiftlang/github-workflows/.github/workflows/soundness.yml@0.0.11

.swift-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
6.2.0
1+
6.3.2

Sources/Subprocess/Platforms/Subprocess+Linux.swift

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -447,17 +447,37 @@ private func _unregisterProcessDescriptorAndNotify(_ pidfd: CInt, context: Monit
447447
newStorage.continuations.removeValue(forKey: pidfd)
448448
state = .started(newStorage)
449449

450-
// Remove this pidfd from epoll to prevent further notifications
451-
let rc = epoll_ctl(
450+
// Remove this pidfd from epoll to prevent further notifications.
451+
// The return value is intentionally not propagated to the continuation:
452+
// epoll firing this event means the process has already exited, so
453+
// monitoring succeeded regardless of cleanup outcome.
454+
//
455+
// ENOENT is silently ignored: it means the fd is not (or is no longer)
456+
// in the epoll instance, which is harmless — this occurs on concurrent
457+
// removals and on older 5.x kernels where epoll_ctl(DEL) incorrectly
458+
// reports ENOENT for pidfds after process exit. The fd is removed from
459+
// epoll automatically by the kernel when processIdentifier.close() closes
460+
// it anyway, so a failed DEL is never permanent.
461+
//
462+
// Any other error (EBADF, EINVAL, …) would indicate a programming error
463+
// in fd lifecycle management — e.g. the pidfd was closed prematurely —
464+
// and is surfaced as an assertion failure in debug builds.
465+
let delRC = epoll_ctl(
452466
context.epollFileDescriptor,
453467
EPOLL_CTL_DEL,
454468
pidfd,
455469
nil
456470
)
457-
if rc != 0 {
458-
let epollErrno = errno
471+
472+
// The pidfd is intentionally left open here. It is owned by
473+
// ProcessIdentifier and will be closed by processIdentifier.close()
474+
// in the defer in Configuration.swift once monitoring is fully done.
475+
// Closing it here would free the fd number and allow it to be recycled
476+
// before that defer runs, causing a close-the-wrong-fd race.
477+
478+
if delRC != 0 && errno != ENOENT {
459479
let error = SubprocessError.failedToMonitor(
460-
withUnderlyingError: Errno(rawValue: epollErrno)
480+
withUnderlyingError: Errno(rawValue: errno)
461481
)
462482
return (continuationList, error)
463483
}
@@ -552,6 +572,7 @@ internal func _isWaitprocessDescriptorSupported() -> Bool {
552572
// If we can not retrieve pidfd, the system does not support waitid(P_PIDFD)
553573
return false
554574
}
575+
defer { try? FileDescriptor(rawValue: selfPidfd).close() }
555576
/// The following call will fail either with
556577
/// - ECHILD: in this case we know P_PIDFD is supported and waitid correctly
557578
/// reported that we don't have a child with the same selfPidfd;

Sources/Subprocess/Platforms/Subprocess+Unix.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,16 @@ extension Configuration {
533533
// Spawn error
534534
if spawnError != 0 {
535535
if [ENOENT, EACCES, ENOTDIR].contains(spawnError) {
536+
// clone3(CLONE_PIDFD) allocates a pidfd before exec runs.
537+
// If exec fails we retry with the next candidate path, so
538+
// close the pidfd here to avoid leaking it across retries.
539+
if processDescriptor != .invalidDescriptor {
540+
do {
541+
try FileDescriptor(rawValue: processDescriptor).close()
542+
} catch {
543+
throw SubprocessError.spawnFailed(withUnderlyingError: error as? SubprocessError.UnderlyingError)
544+
}
545+
}
536546
// Move on to another possible path
537547
continue
538548
}

Sources/_SubprocessCShims/include/process_shims.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#if !TARGET_OS_WINDOWS
1818
#include <pthread.h>
1919
#include <unistd.h>
20+
#include <sys/resource.h>
2021

2122
#if _POSIX_SPAWN
2223
#include <spawn.h>
@@ -95,6 +96,11 @@ int _was_process_signaled(int status);
9596
int _get_signal_code(int status);
9697
int _was_process_suspended(int status);
9798

99+
/// Returns the soft RLIMIT_NOFILE value for the current process, or 0 on
100+
/// error. Implemented in C so that RLIMIT_NOFILE always resolves to the
101+
/// correct type regardless of how the Swift Glibc/Darwin overlay imports it.
102+
uint64_t _subprocess_nofile_soft_limit(void);
103+
98104
void _subprocess_lock_environ(void);
99105
void _subprocess_unlock_environ(void);
100106
char * _Nullable * _Nullable _subprocess_get_environ(void);

Sources/_SubprocessCShims/process_shims.c

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ int _was_process_suspended(int status) {
7878
return WIFSTOPPED(status);
7979
}
8080

81+
uint64_t _subprocess_nofile_soft_limit(void) {
82+
struct rlimit rl;
83+
if (getrlimit(RLIMIT_NOFILE, &rl) != 0) {
84+
return 0;
85+
}
86+
return (uint64_t)rl.rlim_cur;
87+
}
88+
8189
int _subprocess_pthread_create(
8290
pthread_t * _Nonnull ptr,
8391
pthread_attr_t const * _Nullable attr,
@@ -584,6 +592,7 @@ int _subprocess_fork_exec(
584592
if (rc != 0) {
585593
close(pipefd[0]);
586594
close(pipefd[1]);
595+
pthread_mutex_unlock(&_subprocess_fork_lock);
587596
return errno;
588597
}
589598

@@ -602,6 +611,7 @@ int _subprocess_fork_exec(
602611
// Report all other errors
603612
close(pipefd[0]);
604613
close(pipefd[1]);
614+
pthread_mutex_unlock(&_subprocess_fork_lock);
605615
return errno;
606616
}
607617
}
@@ -610,6 +620,7 @@ int _subprocess_fork_exec(
610620
// Fork failed
611621
close(pipefd[0]);
612622
close(pipefd[1]);
623+
pthread_mutex_unlock(&_subprocess_fork_lock);
613624
return errno;
614625
}
615626

@@ -765,6 +776,7 @@ int _subprocess_fork_exec(
765776
// Restore old signmask
766777
rc = pthread_sigmask(SIG_SETMASK, &old_sigmask, NULL);
767778
if (rc != 0) {
779+
pthread_mutex_unlock(&_subprocess_fork_lock);
768780
reap_child_process_and_return_errno;
769781
}
770782

Tests/SubprocessTests/UnixTests.swift

Lines changed: 46 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -929,52 +929,60 @@ extension FileDescriptor {
929929
extension SubprocessUnixTests {
930930
#if SubprocessFoundation
931931
@Test(.requiresBash) func testConcurrentRun() async throws {
932-
// Launch as many processes as we can
933-
// Figure out the max open file limit
934-
let limitResult = try await Subprocess.run(
935-
.path("/bin/sh"),
936-
arguments: ["-c", "ulimit -n"],
937-
output: .string(limit: 32)
938-
)
939-
guard
940-
let limitString = limitResult
941-
.standardOutput?
942-
.trimmingCharacters(in: .whitespacesAndNewlines),
943-
let ulimit = Int(limitString)
944-
else {
945-
Issue.record("Failed to run ulimit -n")
946-
return
947-
}
948-
// 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.
949-
// Common defaults are 2560 for macOS and 1024 for Linux.
950-
let limit = min(ulimit, 4096)
951-
// Since we open two pipes per `run`, launch
952-
// limit / 4 subprocesses should reveal any
953-
// file descriptor leaks
954-
let maxConcurrent = limit / 4
932+
// Read the soft fd limit via a C shim: RLIMIT_NOFILE's Swift type
933+
// varies across platforms and Swift versions, so calling getrlimit
934+
// directly from Swift is not reliably portable.
935+
// Cap at 4096: Docker containers can report limits like 2^20.
936+
let softLimit = Int(min(_subprocess_nofile_soft_limit(), UInt64(4096)))
937+
938+
// On Linux, account for any fds already open (e.g. from prior tests in
939+
// the same suite) to avoid hitting EMFILE during the concurrent spawn
940+
// burst. /proc/self/fd lists every open descriptor; subtracting the
941+
// current count plus a small margin gives the true available headroom.
942+
#if os(Linux) || os(Android)
943+
let currentFds = (try? FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd"))?.count ?? 50
944+
let available = max(32, softLimit - currentFds - 50)
945+
#else
946+
let available = softLimit
947+
#endif
948+
// Each concurrent spawn holds both ends of the stdout and stderr pipes
949+
// plus a temporary exec-error notification pipe while the child's
950+
// exec() completes — roughly 6–8 fds per in-flight spawn. Divide by
951+
// 8 to leave headroom and avoid EMFILE under high concurrency.
952+
let maxConcurrent = available / 8
955953
try await withThrowingTaskGroup(of: Void.self) { group in
956954
var running = 0
957955
let byteCount = 1000
958956
for _ in 0..<maxConcurrent {
959957
group.addTask {
960-
// This invocation specifically requires bash semantics; sh (on FreeBSD at least) does not consistently support -s in this way
961-
let r = try await Subprocess.run(
962-
.name("bash"),
963-
arguments: [
964-
"-sc", #"echo "$1" && echo "$1" >&2"#, "--", String(repeating: "X", count: byteCount),
965-
],
966-
output: .data(limit: .max),
967-
error: .data(limit: .max)
968-
)
969-
guard r.terminationStatus.isSuccess else {
970-
Issue.record("Unexpected exit \(r.terminationStatus) from \(r.processIdentifier)")
971-
return
958+
// Catch errors so a single spawn/monitor failure doesn't
959+
// cascade-cancel sibling tasks (which would SIGKILL their
960+
// live subprocesses and flood the log with false failures).
961+
do {
962+
// This invocation specifically requires bash semantics; sh (on FreeBSD at least) does not consistently support -s in this way
963+
let r = try await Subprocess.run(
964+
.name("bash"),
965+
arguments: [
966+
"-sc", #"echo "$1" && echo "$1" >&2"#, "--", String(repeating: "X", count: byteCount),
967+
],
968+
output: .data(limit: .max),
969+
error: .data(limit: .max)
970+
)
971+
guard r.terminationStatus.isSuccess else {
972+
Issue.record("Unexpected exit \(r.terminationStatus) from \(r.processIdentifier)")
973+
return
974+
}
975+
#expect(r.standardOutput.count == byteCount + 1, "\(r.standardOutput)")
976+
#expect(r.standardError.count == byteCount + 1, "\(r.standardError)")
977+
} catch {
978+
Issue.record("Subprocess.run threw: \(error)")
972979
}
973-
#expect(r.standardOutput.count == byteCount + 1, "\(r.standardOutput)")
974-
#expect(r.standardError.count == byteCount + 1, "\(r.standardError)")
975980
}
976981
running += 1
977-
if running >= maxConcurrent / 4 {
982+
// Throttle to maxConcurrent/8 live subprocesses at a time
983+
// (rather than /4) to reduce peak memory pressure on
984+
// memory-constrained kernel-testing VMs (e.g. QEMU + 5.10).
985+
if running >= maxConcurrent / 8 {
978986
try await group.next()
979987
}
980988
}

scripts/prep-linux-swift.sh

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/bin/bash
2+
##===----------------------------------------------------------------------===##
3+
##
4+
## This source file is part of the Swift.org open source project
5+
##
6+
## Copyright (c) 2026 Apple Inc. and the Swift project authors
7+
## Licensed under Apache License v2.0 with Runtime Library Exception
8+
##
9+
## See https://swift.org/LICENSE.txt for license information
10+
##
11+
##===----------------------------------------------------------------------===##
12+
13+
# This script does a bit of extra preparation of the docker containers used to run the GitHub workflows
14+
# that are specific to this project's needs when building/testing. Note that this script runs on
15+
# every supported Linux distribution so it must adapt to the distribution that it is running.
16+
17+
if [[ "$(uname -s)" == "Linux" ]]; then
18+
# Install the basic utilities depending on the type of Linux distribution
19+
apt-get --help && apt-get update && TZ=Etc/UTC apt-get -y install curl make gpg tzdata
20+
yum --help && (curl --help && yum -y install curl) && yum -y install make gpg tar procps
21+
fi
22+
23+
set -e
24+
25+
while [ $# -ne 0 ]; do
26+
arg="$1"
27+
case "$arg" in
28+
--install-swiftly)
29+
installSwiftly=true
30+
;;
31+
--swift-snapshot)
32+
swiftSnapshot="$2"
33+
shift;
34+
;;
35+
*)
36+
;;
37+
esac
38+
shift
39+
done
40+
41+
if [ "$installSwiftly" == true ]; then
42+
echo "Installing swiftly"
43+
44+
curl -O "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" && tar zxf swiftly-*.tar.gz && ./swiftly init -y --skip-install
45+
# shellcheck source=/dev/null
46+
. "/root/.local/share/swiftly/env.sh"
47+
48+
hash -r
49+
50+
selector=()
51+
runSelector=()
52+
53+
if [ "$swiftSnapshot" != "" ]; then
54+
echo "Installing latest $swiftSnapshot-snapshot toolchain"
55+
selector=("$swiftSnapshot-snapshot")
56+
runSelector=("+$swiftSnapshot-snapshot")
57+
elif [ -f .swift-version ]; then
58+
echo "Installing selected swift toolchain from .swift-version file"
59+
selector=()
60+
runSelector=()
61+
else
62+
echo "Installing latest toolchain"
63+
selector=("latest")
64+
runSelector=("+latest")
65+
fi
66+
67+
TMPDIR=/var/tmp swiftly install --post-install-file=post-install.sh "${selector[@]}"
68+
69+
if [ -f post-install.sh ]; then
70+
echo "Performing swift toolchain post-installation"
71+
chmod u+x post-install.sh && ./post-install.sh
72+
fi
73+
74+
echo "Displaying swift version"
75+
swiftly run "${runSelector[@]}" swift --version
76+
fi

0 commit comments

Comments
 (0)