Skip to content

Commit 939badc

Browse files
Introduce preExecProcessAction on Darwin, rename preSpawnProcessConfigurator to preExecProcessAction on Linux (#140)
This patch introduces a preExecProcessAction on Darwin, which runs immediately before posix_spawn-as-exec, just as preSpawnProcessConfigurator (now called preExecProcessAction) runs immediately before exe on Linux. The reason to introduce this API is because there is presently no way to run code _after_ fork on Darwin in the case where pre-forking occurs (which is true when setting a uid, gid, sgroups, or creating a session). This can be useful when performing operations that modify process global state and wanting to do so without affecting the parent. The reason to rename the Linux API is that these are fundamentally two different logical operations: one is modifying flags to the process spawning mechanism (CreateProcessW, posix_spawn), while the other is performing an action at a specific point in the process lifecycle (before exec). Only Darwin uses posix_spawn, so there is no opportunity to modify any flags, and preSpawnProcessConfigurator is therefore not available on Linux/Android/FreeBSD/etc. On Windows, there is no fork/exec concept, so preExecProcessAction is not available on Windows. This brings the set of APIs to: - preSpawnProcessConfigurator (Windows, Darwin) - modifies flags bitset / STARTUPINFOW, or posix_spawnattr_t / posix_spawn_file_actions_t - preExecProcessAction (Darwin, Linux/Android/FreeBSD/etc.) - runs before exec or posix_spawn-as-exec or - Windows: preSpawnProcessConfigurator - Darwin: preSpawnProcessConfigurator + preExecProcessAction - Linux/Android/FreeBSD/etc.: preExecProcessAction This means that Darwin has both preSpawnProcessConfigurator and preExecProcessAction, because the pre-forking case uses posix_spawn _and_ fork. It also give us the opportunity to introduce preSpawnProcessConfigurator on Linux and other platforms in the future if they introduce a similar POSIX_SPAWN_CLOEXEC_DEFAULT flag or POSIX otherwise standardizes a mechanism to disable file descriptor inheritance by default, since that would allow us to use posix_spawn on those platforms. Note that merely setting preExecProcessAction will also cause pre-forking to occur. Closes #25
1 parent 455ae3f commit 939badc

File tree

7 files changed

+164
-19
lines changed

7 files changed

+164
-19
lines changed

Sources/Subprocess/Platforms/Subprocess+Darwin.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,29 @@ public struct PlatformOptions: Sendable {
8181
inout posix_spawn_file_actions_t?
8282
) throws -> Void
8383
)? = nil
84+
/// A closure to configure platform-specific
85+
/// spawning constructs. This closure enables direct
86+
/// configuration or override of underlying platform-specific
87+
/// spawn settings that `Subprocess` utilizes internally,
88+
/// in cases where Subprocess does not provide higher-level
89+
/// APIs for such modifications.
90+
///
91+
/// On Darwin, Subprocess uses `posix_spawn()` as the
92+
/// underlying spawning mechanism, but may require an initial `fork()`
93+
/// depending on the configured `PlatformOptions`.
94+
/// This closure is called after `fork()` but before `posix_spawn()`
95+
/// (with the `POSIX_SPAWN_SETEXEC` flag set).
96+
/// You may use it to call any necessary process setup functions.
97+
///
98+
/// - note: You can set both `preExecProcessAction` and
99+
/// `preSpawnProcessConfigurator` and both will be called.
100+
/// Setting `preExecProcessAction` will always cause Subprocess
101+
/// to pre-`fork()` before calling `posix_spawn()` (with the
102+
/// `POSIX_SPAWN_SETEXEC` flag set) even if it would not have otherwise
103+
/// done so based on the configured `PlatformOptions`.
104+
///
105+
/// - warning: You may ONLY call [async-signal-safe functions](https://pubs.opengroup.org/onlinepubs/9799919799/functions/V2_chap02.html) within this closure (note _"The following table defines a set of functions and function-like macros that shall be async-signal-safe."_).
106+
public var preExecProcessAction: (@convention(c) @Sendable () -> Void)? = nil
84107

85108
public init() {}
86109
}
@@ -138,6 +161,7 @@ extension PlatformOptions: CustomStringConvertible, CustomDebugStringConvertible
138161
\(indent) processGroupID: \(String(describing: processGroupID)),
139162
\(indent) createSession: \(createSession),
140163
\(indent) preSpawnProcessConfigurator: \(self.preSpawnProcessConfigurator == nil ? "not set" : "set")
164+
\(indent) preExecProcessAction: \(self.preExecProcessAction == nil ? "not set" : "set")
141165
\(indent))
142166
"""
143167
}
@@ -402,7 +426,8 @@ extension Configuration {
402426
gidPtr,
403427
Int32(supplementaryGroups?.count ?? 0),
404428
sgroups?.baseAddress,
405-
self.platformOptions.createSession ? 1 : 0
429+
self.platformOptions.createSession ? 1 : 0,
430+
self.platformOptions.preExecProcessAction,
406431
)
407432
}
408433
}

Sources/Subprocess/Platforms/Subprocess+Linux.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ extension Configuration {
152152
CInt(supplementaryGroups?.count ?? 0),
153153
sgroups?.baseAddress,
154154
self.platformOptions.createSession ? 1 : 0,
155-
self.platformOptions.preSpawnProcessConfigurator
155+
self.platformOptions.preExecProcessAction
156156
)
157157
}
158158
}
@@ -261,27 +261,27 @@ extension ProcessIdentifier: CustomStringConvertible, CustomDebugStringConvertib
261261
/// The collection of platform-specific settings
262262
/// to configure the subprocess when running
263263
public struct PlatformOptions: Sendable {
264-
// Set user ID for the subprocess
264+
/// Set user ID for the subprocess
265265
public var userID: uid_t? = nil
266266
/// Set the real and effective group ID and the saved
267267
/// set-group-ID of the subprocess, equivalent to calling
268268
/// `setgid()` on the child process.
269269
/// Group ID is used to control permissions, particularly
270270
/// for file access.
271271
public var groupID: gid_t? = nil
272-
// Set list of supplementary group IDs for the subprocess
272+
/// Set list of supplementary group IDs for the subprocess
273273
public var supplementaryGroups: [gid_t]? = nil
274274
/// Set the process group for the subprocess, equivalent to
275275
/// calling `setpgid()` on the child process.
276276
/// Process group ID is used to group related processes for
277277
/// controlling signals.
278278
public var processGroupID: pid_t? = nil
279-
// Creates a session and sets the process group ID
280-
// i.e. Detach from the terminal.
279+
/// Creates a session and sets the process group ID
280+
/// i.e. Detach from the terminal.
281281
public var createSession: Bool = false
282282
/// An ordered list of steps in order to tear down the child
283283
/// process in case the parent task is cancelled before
284-
/// the child proces terminates.
284+
/// the child process terminates.
285285
/// Always ends in sending a `.kill` signal at the end.
286286
public var teardownSequence: [TeardownStep] = []
287287
/// A closure to configure platform-specific
@@ -295,7 +295,9 @@ public struct PlatformOptions: Sendable {
295295
/// underlying spawning mechanism. This closure is called
296296
/// after `fork()` but before `exec()`. You may use it to
297297
/// call any necessary process setup functions.
298-
public var preSpawnProcessConfigurator: (@convention(c) @Sendable () -> Void)? = nil
298+
///
299+
/// - warning: You may ONLY call [async-signal-safe functions](https://pubs.opengroup.org/onlinepubs/9799919799/functions/V2_chap02.html) within this closure (note _"The following table defines a set of functions and function-like macros that shall be async-signal-safe."_).
300+
public var preExecProcessAction: (@convention(c) @Sendable () -> Void)? = nil
299301

300302
public init() {}
301303
}
@@ -310,7 +312,7 @@ extension PlatformOptions: CustomStringConvertible, CustomDebugStringConvertible
310312
\(indent) supplementaryGroups: \(String(describing: supplementaryGroups)),
311313
\(indent) processGroupID: \(String(describing: processGroupID)),
312314
\(indent) createSession: \(createSession),
313-
\(indent) preSpawnProcessConfigurator: \(self.preSpawnProcessConfigurator == nil ? "not set" : "set")
315+
\(indent) preExecProcessAction: \(self.preExecProcessAction == nil ? "not set" : "set")
314316
\(indent))
315317
"""
316318
}

Sources/_SubprocessCShims/include/process_shims.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ int _subprocess_spawn(
4343
uid_t * _Nullable uid,
4444
gid_t * _Nullable gid,
4545
int number_of_sgroups, const gid_t * _Nullable sgroups,
46-
int create_session
46+
int create_session,
47+
void (* _Nullable configurator)(void)
4748
);
4849
#endif // TARGET_OS_MAC
4950

Sources/_SubprocessCShims/process_shims.c

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ static int _subprocess_spawn_prefork(
143143
uid_t * _Nullable uid,
144144
gid_t * _Nullable gid,
145145
int number_of_sgroups, const gid_t * _Nullable sgroups,
146-
int create_session
146+
int create_session,
147+
void (* _Nullable configurator)(void)
147148
) {
148149
#define write_error_and_exit int error = errno; \
149150
write(pipefd[1], &error, sizeof(error));\
@@ -212,6 +213,9 @@ static int _subprocess_spawn_prefork(
212213

213214
// Perform setups
214215
if (number_of_sgroups > 0 && sgroups != NULL) {
216+
// POSIX doesn't define setgroups (only getgroups) and therefore makes no guarantee of async-signal-safety,
217+
// but we'll assume in practice it should be async-signal-safe on any reasonable platform based on the fact
218+
// that getgroups is async-signal-safe.
215219
if (setgroups(number_of_sgroups, sgroups) != 0) {
216220
write_error_and_exit;
217221
}
@@ -233,6 +237,11 @@ static int _subprocess_spawn_prefork(
233237
(void)setsid();
234238
}
235239

240+
// Run custom configuratior
241+
if (configurator != NULL) {
242+
configurator();
243+
}
244+
236245
// Use posix_spawnas exec
237246
int error = posix_spawn(pid, exec_path, file_actions, spawn_attrs, args, env);
238247
// If we reached this point, something went wrong
@@ -281,20 +290,22 @@ int _subprocess_spawn(
281290
uid_t * _Nullable uid,
282291
gid_t * _Nullable gid,
283292
int number_of_sgroups, const gid_t * _Nullable sgroups,
284-
int create_session
293+
int create_session,
294+
void (* _Nullable configurator)(void)
285295
) {
286296
int require_pre_fork = uid != NULL ||
287297
gid != NULL ||
288298
number_of_sgroups > 0 ||
289-
create_session > 0;
299+
create_session > 0 ||
300+
configurator != NULL;
290301

291302
if (require_pre_fork != 0) {
292303
int rc = _subprocess_spawn_prefork(
293304
pid,
294305
exec_path,
295306
file_actions, spawn_attrs,
296307
args, env,
297-
uid, gid, number_of_sgroups, sgroups, create_session
308+
uid, gid, number_of_sgroups, sgroups, create_session, configurator
298309
);
299310
return rc;
300311
}
@@ -490,6 +501,9 @@ int _subprocess_fork_exec(
490501
}
491502

492503
if (number_of_sgroups > 0 && sgroups != NULL) {
504+
// POSIX doesn't define setgroups (only getgroups) and therefore makes no guarantee of async-signal-safety,
505+
// but we'll assume in practice it should be async-signal-safe on any reasonable platform based on the fact
506+
// that getgroups is async-signal-safe.
493507
if (setgroups(number_of_sgroups, sgroups) != 0) {
494508
write_error_and_exit;
495509
}

Tests/SubprocessTests/SubprocessTests+Darwin.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,75 @@ import Testing
2626
// MARK: PlatformOptions Tests
2727
@Suite(.serialized)
2828
struct SubprocessDarwinTests {
29+
@Test func testSubprocessPlatformOptionsPreExecProcessAction() async throws {
30+
var platformOptions = PlatformOptions()
31+
platformOptions.preExecProcessAction = {
32+
exit(1234567)
33+
}
34+
let idResult = try await Subprocess.run(
35+
.path("/bin/pwd"),
36+
platformOptions: platformOptions,
37+
output: .discarded
38+
)
39+
#expect(idResult.terminationStatus == .exited(1234567))
40+
}
41+
42+
@Test func testSubprocessPlatformOptionsPreExecProcessActionAndProcessConfigurator() async throws {
43+
let (readFD, writeFD) = try FileDescriptor.pipe()
44+
try await readFD.closeAfter {
45+
let childPID = try await writeFD.closeAfter {
46+
// Allocate some constant high-numbered FD that's unlikely to be used.
47+
let specialFD = try writeFD.duplicate(as: FileDescriptor(rawValue: 9000))
48+
return try await specialFD.closeAfter {
49+
// Make the fd non-blocking just to avoid the test hanging if it fails
50+
let opts = fcntl(specialFD.rawValue, F_GETFD)
51+
#expect(opts >= 0)
52+
#expect(fcntl(specialFD.rawValue, F_SETFD, opts | O_NONBLOCK) >= 0)
53+
54+
var platformOptions = PlatformOptions()
55+
platformOptions.preExecProcessAction = {
56+
var pid: Int32 = getpid()
57+
if write(9000, &pid, 4) != 4 {
58+
exit(EXIT_FAILURE)
59+
}
60+
}
61+
platformOptions.preSpawnProcessConfigurator = { spawnAttr, _ in
62+
// Set POSIX_SPAWN_SETSID flag, which implies calls
63+
// to setsid
64+
var flags: Int16 = 0
65+
posix_spawnattr_getflags(&spawnAttr, &flags)
66+
posix_spawnattr_setflags(&spawnAttr, flags | Int16(POSIX_SPAWN_SETSID))
67+
}
68+
// Check the process ID (pid), process group ID (pgid), and
69+
// controlling terminal's process group ID (tpgid)
70+
let psResult = try await Subprocess.run(
71+
.path("/bin/bash"),
72+
arguments: ["-c", "ps -o pid,pgid,tpgid -p $$"],
73+
platformOptions: platformOptions,
74+
input: .none,
75+
error: .discarded
76+
) { execution, standardOutput in
77+
var buffer = Data()
78+
for try await chunk in standardOutput {
79+
let currentChunk = chunk.withUnsafeBytes { Data($0) }
80+
buffer += currentChunk
81+
}
82+
return (pid: execution.processIdentifier.value, standardOutput: buffer)
83+
}
84+
try assertNewSessionCreated(terminationStatus: psResult.terminationStatus, output: String(decoding: psResult.value.standardOutput, as: UTF8.self))
85+
return psResult.value.pid
86+
}
87+
}
88+
89+
let bytes = try await readFD.readUntilEOF(upToLength: 4)
90+
var pid: Int32 = -1
91+
_ = withUnsafeMutableBytes(of: &pid) { ptr in
92+
bytes.copyBytes(to: ptr)
93+
}
94+
#expect(pid == childPID)
95+
}
96+
}
97+
2998
@Test func testSubprocessPlatformOptionsProcessConfiguratorUpdateSpawnAttr() async throws {
3099
var platformOptions = PlatformOptions()
31100
platformOptions.preSpawnProcessConfigurator = { spawnAttr, _ in

Tests/SubprocessTests/SubprocessTests+Linux.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ struct SubprocessLinuxTests {
3434
"This test requires root privileges"
3535
)
3636
)
37-
func testSubprocessPlatformOptionsPreSpawnProcessConfigurator() async throws {
37+
func testSubprocessPlatformOptionsPreExecProcessAction() async throws {
3838
var platformOptions = PlatformOptions()
39-
platformOptions.preSpawnProcessConfigurator = {
39+
platformOptions.preExecProcessAction = {
4040
guard setgid(4321) == 0 else {
4141
// Returns EPERM when:
4242
// The calling process is not privileged (does not have the

Tests/SubprocessTests/SubprocessTests+Unix.swift

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,32 @@ extension SubprocessUnixTests {
955955
}
956956

957957
// MARK: - Utils
958+
extension FileDescriptor {
959+
/// Runs a closure and then closes the FileDescriptor, even if an error occurs.
960+
///
961+
/// - Parameter body: The closure to run.
962+
/// If the closure throws an error,
963+
/// this method closes the file descriptor before it rethrows that error.
964+
///
965+
/// - Returns: The value returned by the closure.
966+
///
967+
/// If `body` throws an error
968+
/// or an error occurs while closing the file descriptor,
969+
/// this method rethrows that error.
970+
public func closeAfter<R>(_ body: () async throws -> R) async throws -> R {
971+
// No underscore helper, since the closure's throw isn't necessarily typed.
972+
let result: R
973+
do {
974+
result = try await body()
975+
} catch {
976+
_ = try? self.close() // Squash close error and throw closure's
977+
throw error
978+
}
979+
try self.close()
980+
return result
981+
}
982+
}
983+
958984
extension SubprocessUnixTests {
959985
private func assertID(
960986
withArgument argument: String,
@@ -981,10 +1007,18 @@ internal func assertNewSessionCreated<Output: OutputProtocol>(
9811007
Output
9821008
>
9831009
) throws {
984-
#expect(result.terminationStatus.isSuccess)
985-
let psValue = try #require(
986-
result.standardOutput
1010+
try assertNewSessionCreated(
1011+
terminationStatus: result.terminationStatus,
1012+
output: #require(result.standardOutput)
9871013
)
1014+
}
1015+
1016+
internal func assertNewSessionCreated(
1017+
terminationStatus: TerminationStatus,
1018+
output psValue: String
1019+
) throws {
1020+
#expect(terminationStatus.isSuccess)
1021+
9881022
let match = try #require(try #/\s*PID\s*PGID\s*TPGID\s*(?<pid>[\-]?[0-9]+)\s*(?<pgid>[\-]?[0-9]+)\s*(?<tpgid>[\-]?[0-9]+)\s*/#.wholeMatch(in: psValue), "ps output was in an unexpected format:\n\n\(psValue)")
9891023
// If setsid() has been called successfully, we should observe:
9901024
// - pid == pgid

0 commit comments

Comments
 (0)