pipe2/dup3 O_CLOEXEC - #373
Merged
Merged
Conversation
jakepetroules
force-pushed
the
pipe2-dup3-cloexec
branch
4 times, most recently
from
September 9, 2026 09:43
e3d3710 to
17692f6
Compare
iCharlesHu
approved these changes
Sep 17, 2026
Contributor
|
@jakepetroules macOS failure seems related. |
pipe() + a separate fcntl(F_SETFD, FD_CLOEXEC), and dup()/dup2() + the same, are not atomic: a fork() racing on another thread between the two calls can inherit the not-yet-marked descriptor into an unrelated child this library spawns concurrently. Add _subprocess_pipe_cloexec, _subprocess_dup3_cloexec, and _subprocess_dup_cloexec to _SubprocessCShims. On Linux/FreeBSD/OpenBSD these call pipe2/dup3 directly (declared unconditionally there). On Darwin, which does not yet declare pipe2/dup3, they check for <Availability.h>'s __MAC_27_0 (only defined once the SDK knows about a macOS release new enough to vend them) and use __builtin_available to call the real syscalls once that SDK ships, falling back to the old non-atomic sequence otherwise -- the same technique swift-system's own CSystem shim uses, and the same fix applied to swift-build in swiftlang/swift-build#1520. Route every internal pipe/dup creation in the Swift layer (the self-pipe used to report exec() failures back to the parent, the Linux/BSD/kqueue self-pipes used for shutdown/signal notification, and the pipes/duplicated descriptors used for a subprocess's own stdio) through these new primitives via FileDescriptor.cloexecPipe() and FileDescriptor.safeDuplicate(). dup2 calls that bind a spawned child's stdin/stdout/stderr are intentionally left alone, since those need the descriptor to survive exec(), not be closed by it.
jakepetroules
force-pushed
the
pipe2-dup3-cloexec
branch
from
September 17, 2026 17:33
17692f6 to
11b8b25
Compare
iCharlesHu
approved these changes
Sep 17, 2026
|
Hi! Came across this. Does this mean macOS 27 (finally?) has atomic CLOEXEC for pipes? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
pipe()+ a separatefcntl(F_SETFD, FD_CLOEXEC)(and the equivalent fordup/dup2) is not atomic: afork()racing on another thread between the two calls can inherit the not-yet-marked descriptor into an unrelated child this library spawns concurrently._subprocess_pipe_cloexec,_subprocess_dup3_cloexec, and_subprocess_dup_cloexecto_SubprocessCShims. On Linux/FreeBSD/OpenBSD these callpipe2/dup3directly (declared unconditionally there). On Darwin, which does not yet declarepipe2/dup3, they check for<Availability.h>'s__MAC_27_0(only defined once the SDK knows about a macOS release new enough to vend them) and use__builtin_availableto call the real syscalls once that SDK ships, falling back to the old non-atomic sequence otherwise -- the same technique swift-system's ownCSystemshim uses, and the same fix applied to swift-build in pipe2/dup3 O_CLOEXEC swift-build#1520.FileDescriptor.cloexecPipe()/FileDescriptor.safeDuplicate(): the self-pipe used to reportexec()failures back to the parent, the Linux/BSD/kqueue self-pipes used for shutdown/signal notification, and the pipes/duplicated descriptors used for a subprocess's own stdio._subprocess_dup3_cloexecitself has no caller yet -- included for parity with_subprocess_pipe_cloexecso a future one doesn't have to reimplement the SDK-availability handling.Two places that need the descriptors to not be close-on-exec
Making the stdio pipes close-on-exec exposed two spots where a descriptor is deliberately meant to survive into the child. Both are handled explicitly rather than by reverting to non-atomic creation, so the parent's descriptors stay close-on-exec for their whole lifetime and the fork race stays closed.
createSession/uid/gid/supplementary groups). That path spawns withPOSIX_SPAWN_SETEXEC, which makesposix_spawn()exec the calling process instead of creating a new one. In that mode the exec-time close-on-exec sweep runs before the file actions are applied, so any close-on-exec descriptor named by anadddup2/addcloseaction is already gone and the whole spawn fails withEBADF. This is what broketestSubprocessPlatformOptionsCreateSessionon macOS CI. Reproduced standalone: it happens foradddup2sources andaddclosetargets alike, and with or withoutPOSIX_SPAWN_CLOEXEC_DEFAULT. Fixed by passing the file-action descriptor list down to_subprocess_spawnand clearingFD_CLOEXECon them in the forked child, which is a private copy about to exec. Nothing leaks into the exec'd image:POSIX_SPAWN_CLOEXEC_DEFAULTcloses everything the file actions don't install, and the parent-side ends are closed outright byaddclose. The ordinary (non-SETEXEC)posix_spawn()path is unaffected.fork/execpath. Binding the child's stdio withdup2is still correct --dup2clears close-on-exec on the descriptor it creates regardless of the source -- except whenfd == target, wheredup2is a documented no-op that leaves the flag untouched. That can happen when the parent had that standard descriptor closed and the pipe got allocated over it, and with close-on-exec pipes it would leave the child's stream closed afterexec._subprocess_bind_standard_fdnow clears the flag explicitly in that case. (posix_spawn_file_actions_adddup2already specifies the equal-descriptor case as clearingFD_CLOEXEC, so the Darwin path needs nothing here.)Test plan
swift teston macOS 27: 171 tests in 7 suites pass, includingtestSubprocessPlatformOptionsCreateSession, which fails withEBADFwithout the pre-fork fix above and passes with it. Verified the same test passes on an unmodifiedmaincheckout, confirming the failure was introduced here and is now resolved.POSIX_SPAWN_SETEXECbehavior and to check_subprocess_bind_standard_fd's equal-descriptor handling (that path is Linux/BSD-only and isn't compiled on macOS)._SubprocessCShimscompiles cleanly with-Wall -Wextra.swift format lint --configuration .swift-format --strict -r Sources/Subprocess Sources/_SubprocessCShims