Skip to content

Commit 371baa0

Browse files
Target process group during teardown when configured (#243)
1 parent 0dd45aa commit 371baa0

2 files changed

Lines changed: 121 additions & 9 deletions

File tree

Sources/Subprocess/Teardown.swift

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ import Musl
3131
public struct TeardownStep: Sendable, Hashable {
3232
internal enum Storage: Sendable, Hashable {
3333
#if !os(Windows)
34-
case sendSignal(Signal, allowedDuration: Duration)
34+
case sendSignal(Signal, toProcessGroup: Bool, allowedDuration: Duration)
3535
#endif
36-
case gracefulShutDown(allowedDuration: Duration)
36+
case gracefulShutDown(toProcessGroup: Bool, allowedDuration: Duration)
3737
case kill
3838
}
3939
var storage: Storage
@@ -43,13 +43,22 @@ public struct TeardownStep: Sendable, Hashable {
4343
/// before proceeding to the next step.
4444
///
4545
/// The final step in the sequence always sends a `.kill` signal.
46+
///
47+
/// - Important: When sending the signal to the process group, unless you
48+
/// also set `createSession` to `true`, or `processGroupID` to a
49+
/// non-inherited value, the targeted process group includes the parent
50+
/// process. This is almost never what you want. Pair `toProcessGroup`
51+
/// with `createSession` to isolate the subprocess and its descendants in
52+
/// their own session.
4653
public static func send(
4754
signal: Signal,
55+
toProcessGroup: Bool = false,
4856
allowedDurationToNextStep: Duration
4957
) -> Self {
5058
return Self(
5159
storage: .sendSignal(
5260
signal,
61+
toProcessGroup: toProcessGroup,
5362
allowedDuration: allowedDurationToNextStep
5463
)
5564
)
@@ -64,11 +73,22 @@ public struct TeardownStep: Sendable, Hashable {
6473
/// 1. Sends `WM_CLOSE` if the child process is a GUI process.
6574
/// 2. Sends `CTRL_C_EVENT` to the console.
6675
/// 3. Sends `CTRL_BREAK_EVENT` to the process group.
76+
///
77+
/// - Important: On Unix, when sending the signal to the process group,
78+
/// unless you also set `createSession` to `true`, or `processGroupID`
79+
/// to a non-inherited value, the targeted process group includes the parent
80+
/// process. This is almost never what you want. Pair `toProcessGroup`
81+
/// with `createSession` to isolate the subprocess and its descendants in
82+
/// their own session. On Windows, the `toProcessGroup` parameter has no
83+
/// effect; `WM_CLOSE` and `CTRL_C_EVENT` have no process-group equivalent,
84+
/// and `CTRL_BREAK_EVENT` is always sent to the process group.
6785
public static func gracefulShutDown(
86+
toProcessGroup: Bool = false,
6887
allowedDurationToNextStep: Duration
6988
) -> Self {
7089
return Self(
7190
storage: .gracefulShutDown(
91+
toProcessGroup: toProcessGroup,
7292
allowedDuration: allowedDurationToNextStep
7393
)
7494
)
@@ -95,6 +115,7 @@ internal enum TeardownStepCompletion {
95115

96116
extension Execution {
97117
internal func gracefulShutDown(
118+
toProcessGroup: Bool,
98119
allowedDurationToNextStep duration: Duration
99120
) async {
100121
#if os(Windows)
@@ -129,16 +150,30 @@ extension Execution {
129150
// Send SIGTERM
130151
try? self.send(
131152
signal: .terminate,
132-
toProcessGroup: false
153+
toProcessGroup: toProcessGroup
133154
)
134155
#endif
135156
}
136157

137158
internal func runTeardownSequence(
138159
_ sequence: some Sequence<TeardownStep> & Sendable
139160
) async {
140-
// First insert the `.kill` step
141-
let finalSequence = sequence + [TeardownStep(storage: .kill)]
161+
// The implicit final `.kill` inherits `toProcessGroup` from the last
162+
// explicit step in the sequence. This matches user intent: A sequence
163+
// configured to target the process group should have its terminal kill
164+
// also target the group, so descendants don't leak after teardown.
165+
let steps = Array(sequence)
166+
let killProcessGroup: Bool = {
167+
guard let last = steps.last else { return false }
168+
switch last.storage {
169+
#if !os(Windows)
170+
case .sendSignal(_, let toProcessGroup, _): return toProcessGroup
171+
#endif
172+
case .gracefulShutDown(let toProcessGroup, _): return toProcessGroup
173+
case .kill: return false
174+
}
175+
}()
176+
let finalSequence = steps + [TeardownStep(storage: .kill)]
142177
for step in finalSequence {
143178
let stepCompletion: TeardownStepCompletion
144179
guard self.isPotentiallyStillAlive() else {
@@ -147,7 +182,7 @@ extension Execution {
147182
}
148183

149184
switch step.storage {
150-
case .gracefulShutDown(let allowedDuration):
185+
case .gracefulShutDown(let toProcessGroup, let allowedDuration):
151186
stepCompletion = await withTaskGroup(of: TeardownStepCompletion.self) { group in
152187
group.addTask {
153188
do {
@@ -160,12 +195,13 @@ extension Execution {
160195
}
161196
}
162197
await self.gracefulShutDown(
198+
toProcessGroup: toProcessGroup,
163199
allowedDurationToNextStep: allowedDuration
164200
)
165201
return await group.next()!
166202
}
167203
#if !os(Windows)
168-
case .sendSignal(let signal, let allowedDuration):
204+
case .sendSignal(let signal, let toProcessGroup, let allowedDuration):
169205
stepCompletion = await withTaskGroup(of: TeardownStepCompletion.self) { group in
170206
group.addTask {
171207
do {
@@ -177,7 +213,7 @@ extension Execution {
177213
return .processHasExited
178214
}
179215
}
180-
try? self.send(signal: signal, toProcessGroup: false)
216+
try? self.send(signal: signal, toProcessGroup: toProcessGroup)
181217
return await group.next()!
182218
}
183219
#endif // !os(Windows)
@@ -187,7 +223,7 @@ extension Execution {
187223
withExitCode: 0
188224
)
189225
#else
190-
try? self.send(signal: .kill, toProcessGroup: false)
226+
try? self.send(signal: .kill, toProcessGroup: killProcessGroup)
191227
#endif
192228
stepCompletion = .killedTheProcess
193229
}

Tests/SubprocessTests/UnixTests.swift

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,82 @@ extension SubprocessUnixTests {
382382
}
383383
}
384384

385+
@Test(.requiresBash)
386+
func testTeardownSignalsProcessGroup() async throws {
387+
do {
388+
try await withThrowingTaskGroup { group in
389+
let (readyStream, readyContinuation) = AsyncStream.makeStream(of: Void.self)
390+
391+
group.addTask {
392+
var platformOptions = PlatformOptions()
393+
// Creating a new session puts the shell (and its descendants,
394+
// absent further setsid calls) in their own process group, so
395+
// the teardown signal reaches everything spawned from the shell.
396+
platformOptions.createSession = true
397+
platformOptions.teardownSequence = [
398+
.send(signal: .terminate, toProcessGroup: true, allowedDurationToNextStep: .milliseconds(200))
399+
]
400+
let result = try await Subprocess.run(
401+
.name("bash"),
402+
arguments: [
403+
"-c",
404+
"""
405+
set -e
406+
# Spawn a grandchild that would otherwise outlive the shell.
407+
# Deliberately install NO trap: we want to verify that the
408+
# teardown signal reaches the grandchild directly via the
409+
# process group, not that bash cooperatively cleans up.
410+
/usr/bin/yes "Runaway process from \(#function), please file a SwiftSubprocess bug." > /dev/null &
411+
child_pid=$!
412+
echo "$child_pid"
413+
wait $child_pid
414+
""",
415+
],
416+
platformOptions: platformOptions,
417+
error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false),
418+
preferredBufferSize: 1
419+
) { _, standardOutput in
420+
var grandChildPid: pid_t?
421+
for try await line in standardOutput.strings() {
422+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
423+
if let pid = pid_t(trimmed) {
424+
grandChildPid = pid
425+
readyContinuation.finish()
426+
}
427+
}
428+
return grandChildPid
429+
}
430+
#expect(result.terminationStatus == .signaled(SIGTERM))
431+
let grandChildPid = try #require(result.value)
432+
// Grandchild should have been signalled via the process group.
433+
// Allow a few iterations for signal propagation and reaping.
434+
for _ in 0..<10 {
435+
if kill(grandChildPid, 0) != 0 { break }
436+
try await Task.sleep(for: .milliseconds(100))
437+
}
438+
let finalRC = kill(grandChildPid, 0)
439+
let capturedError = errno
440+
#expect(finalRC != 0)
441+
#expect(capturedError == ESRCH)
442+
}
443+
group.addTask {
444+
for await _ in readyStream {}
445+
}
446+
// Wait for the ready signal
447+
_ = try await group.next()
448+
// Cancel child process to trigger teardown
449+
group.cancelAll()
450+
try await group.waitForAll()
451+
}
452+
} catch {
453+
if error is CancellationError {
454+
// We intentionally cancelled the task
455+
return
456+
}
457+
throw error
458+
}
459+
}
460+
385461
@Test func testSubprocessDoesNotInheritVeryHighFileDescriptors() async throws {
386462
var openedFileDescriptors: [CInt] = []
387463
// Open /dev/null to use as source for duplication

0 commit comments

Comments
 (0)