Skip to content

Commit d173b7d

Browse files
committed
Add ResourceUsage to report subprocess CPU time and memory consumption
Introduce a public ResourceUsage struct that exposes userTime, systemTime (as Duration), and maxRSS (in bytes) for every terminated subprocess. An ExecutionResult protocol provides common access to terminationStatus and resourceUsage across both ExecutionOutcome and ExecutionRecord. On Unix, resource data is collected via wait4 (BSD) or the Linux kernel's 5-argument waitid syscall, which populates a rusage struct alongside the termination status. A linux_waitid C shim is added because glibc and musl only expose the 4-parameter POSIX waitid that omits rusage. The raw rusage is available as a public property on non-Windows platforms, with maxRSS normalized from KiB to bytes on Linux, FreeBSD, and OpenBSD. On Windows, GetProcessTimes provides CPU time (converted from FILETIME 100ns units) and GetProcessMemoryInfo provides PeakWorkingSetSize (maxRSS). This functionality is particularly necessary as part of SwiftSubprocess because collecting rusage information from a terminated subprocess requires the ability to run code when a process has changed state from executing to zombie, but before its pid has been reaped - something not possible with the current Subprocess API. Further, it is notoriously difficult to collect this information across all OSes for an arbitrary PID anyways, at least in a way that doesn't also simultaneously reap the pid. User time, system time, and maxRSS are some of the most common metrics typically extracted from getrusage. Exposing the raw rusage struct provides access to the rest, and on Windows, callers can use DuplicateHandle to get a process descriptor that can outlive Subprocess's control of the process and collect any additional metrics from the process when it is known to be in a terminated state.
1 parent 745d4a7 commit d173b7d

10 files changed

Lines changed: 263 additions & 61 deletions

File tree

Sources/Subprocess/API.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ public func run<
369369
return ExecutionResult(
370370
processIdentifier: result.value.processIdentifier,
371371
terminationStatus: result.terminationStatus,
372+
resourceUsage: result.resourceUsage,
372373
closureOutput: result.value.closureResult,
373374
standardOutput: result.value.output,
374375
standardError: result.value.error

Sources/Subprocess/Configuration.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,13 @@ public struct Configuration: Sendable {
131131
// even if `body` throws, and we are not leaving zombie processes in the
132132
// process table which will cause the process termination monitoring thread
133133
// to effectively hang due to the pid never being awaited
134-
let terminationStatus = try await monitorProcessTermination(
134+
let (terminationStatus, resourceUsage) = try await monitorProcessTermination(
135135
for: processIdentifier
136136
)
137137

138138
return ExecutionOutcome(
139139
terminationStatus: terminationStatus,
140+
resourceUsage: resourceUsage,
140141
value: try result.get()
141142
)
142143
} onCleanup: {

Sources/Subprocess/Platforms/Subprocess+BSD.swift

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ internal import Dispatch
2929
@Sendable
3030
internal func monitorProcessTermination(
3131
for processIdentifier: ProcessIdentifier
32-
) async throws(SubprocessError) -> TerminationStatus {
33-
switch Result(catching: { () throws(Errno) -> TerminationStatus? in try processIdentifier.reap() }) {
34-
case let .success(status?):
35-
return status
32+
) async throws(SubprocessError) -> (TerminationStatus, ResourceUsage) {
33+
switch Result(catching: { () throws(Errno) -> (TerminationStatus, ResourceUsage)? in try processIdentifier.reap() }) {
34+
case let .success(result?):
35+
return result
3636
case .success(nil):
3737
break
3838
case let .failure(error):
@@ -50,10 +50,9 @@ internal func monitorProcessTermination(
5050

5151
do throws(Errno) {
5252
// NOTE_EXIT may be delivered slightly before the process becomes reapable,
53-
// so we must call waitid without WNOHANG to avoid a narrow possibility of a race condition.
54-
// If waitid does block, it won't do so for very long at all.
55-
let status = try processIdentifier.blockingReap()
56-
continuation.resume(returning: status)
53+
// so we must call wait4 without WNOHANG to avoid a narrow possibility of a race condition.
54+
// If wait4 does block, it won't do so for very long at all.
55+
continuation.resume(returning: try processIdentifier.blockingReap())
5756
} catch {
5857
let subprocessError: SubprocessError = .failedToMonitor(withUnderlyingError: error)
5958
continuation.resume(throwing: subprocessError)

Sources/Subprocess/Platforms/Subprocess+Linux.swift

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ extension Int32 {
6868
@Sendable
6969
internal func monitorProcessTermination(
7070
for processIdentifier: ProcessIdentifier
71-
) async throws(SubprocessError) -> TerminationStatus {
71+
) async throws(SubprocessError) -> (TerminationStatus, ResourceUsage) {
7272
return try await _castError {
7373
return try await withCheckedThrowingContinuation { continuation in
74-
let status = _processMonitorState.withLock { state -> Result<TerminationStatus, SubprocessError>? in
74+
let status = _processMonitorState.withLock { state -> Result<(TerminationStatus, ResourceUsage), SubprocessError>? in
7575
switch state {
7676
case .notStarted:
7777
let error: SubprocessError = .failedToMonitor(withUnderlyingError: nil)
@@ -112,9 +112,9 @@ internal func monitorProcessTermination(
112112
// Since Linux coalesce signals, it's possible by the time we request
113113
// monitoring the process has already exited. Check to make sure that
114114
// is not the case and only save continuation then.
115-
switch Result(catching: { () throws(Errno) -> TerminationStatus? in try processIdentifier.reap() }) {
116-
case let .success(status?):
117-
return .success(status)
115+
switch Result(catching: { () throws(Errno) -> (TerminationStatus, ResourceUsage)? in try processIdentifier.reap() }) {
116+
case let .success(result?):
117+
return .success(result)
118118
case .success(nil):
119119
// Save this continuation to be called by signal handler
120120
var newState = storage
@@ -147,7 +147,7 @@ private enum ProcessMonitorState {
147147
let epollFileDescriptor: CInt
148148
let shutdownFileDescriptor: CInt
149149
let monitorThread: pthread_t
150-
var continuations: [PlatformFileDescriptor: CheckedContinuation<TerminationStatus, any Error>]
150+
var continuations: [PlatformFileDescriptor: CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>]
151151
}
152152

153153
case notStarted
@@ -238,8 +238,8 @@ private func monitorThreadFunc(context: MonitorThreadContext) {
238238
let error: SubprocessError = .failedToMonitor(
239239
withUnderlyingError: Errno(rawValue: pwaitErrno)
240240
)
241-
let continuations = _processMonitorState.withLock { state -> [CheckedContinuation<TerminationStatus, any Error>] in
242-
let result: [CheckedContinuation<TerminationStatus, any Error>]
241+
let continuations = _processMonitorState.withLock { state -> [CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>] in
242+
let result: [CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>]
243243
if case .started(let storage) = state {
244244
result = Array(storage.continuations.values)
245245
} else {
@@ -406,8 +406,17 @@ internal func _setupMonitorSignalHandler() {
406406
}
407407

408408
private func _blockAndWaitForProcessDescriptor(_ pidfd: CInt, context: MonitorThreadContext) {
409-
var terminationStatus = Result(catching: { () throws(Errno) in
410-
try TerminationStatus(_waitid(idtype: idtype_t(UInt32(P_PIDFD)), id: id_t(pidfd), flags: WEXITED))
409+
var terminationResult = Result(catching: { () throws(Errno) -> (TerminationStatus, ResourceUsage) in
410+
while true {
411+
var siginfo = siginfo_t()
412+
var usage = rusage()
413+
let rc = linux_waitid(idtype_t(UInt32(P_PIDFD)), id_t(pidfd), &siginfo, WEXITED, &usage)
414+
if rc != -1 {
415+
return (TerminationStatus(siginfo), ResourceUsage(usage))
416+
} else if errno != EINTR {
417+
throw Errno(rawValue: errno)
418+
}
419+
}
411420
}).mapError { underlyingError in
412421
return SubprocessError.failedToMonitor(withUnderlyingError: underlyingError)
413422
}
@@ -421,14 +430,14 @@ private func _blockAndWaitForProcessDescriptor(_ pidfd: CInt, context: MonitorTh
421430
)
422431
if rc != 0 {
423432
let epollErrno = errno
424-
terminationStatus = .failure(
433+
terminationResult = .failure(
425434
SubprocessError.failedToMonitor(
426435
withUnderlyingError: Errno(rawValue: epollErrno)
427436
)
428437
)
429438
}
430439
// Notify the continuation
431-
let continuation = _processMonitorState.withLock { state -> CheckedContinuation<TerminationStatus, any Error>? in
440+
let continuation = _processMonitorState.withLock { state -> CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>? in
432441
guard case .started(let storage) = state,
433442
let continuation = storage.continuations[pidfd]
434443
else {
@@ -440,13 +449,13 @@ private func _blockAndWaitForProcessDescriptor(_ pidfd: CInt, context: MonitorTh
440449
state = .started(newStorage)
441450
return continuation
442451
}
443-
continuation?.resume(with: terminationStatus)
452+
continuation?.resume(with: terminationResult)
444453
}
445454

446455
// On older kernels, fall back to using signal handlers
447456
private typealias ResultContinuation = (
448-
result: Result<TerminationStatus, SubprocessError>,
449-
continuation: CheckedContinuation<TerminationStatus, any Error>
457+
result: Result<(TerminationStatus, ResourceUsage), SubprocessError>,
458+
continuation: CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>
450459
)
451460
private func _reapAllKnownChildProcesses(_ signalFd: CInt, context: MonitorThreadContext) {
452461
guard signalFd == _signalPipe.readEnd else {
@@ -470,19 +479,19 @@ private func _reapAllKnownChildProcesses(_ signalFd: CInt, context: MonitorThrea
470479
// Since Linux coalesce signals, we need to loop through all known child process
471480
// to check if they exited.
472481
loop: for (knownChildPID, continuation) in storage.continuations {
473-
let terminationStatus: Result<TerminationStatus, SubprocessError>
474-
switch Result(catching: { () throws(Errno) -> TerminationStatus? in try _reap(pid: knownChildPID) }) {
475-
case let .success(status?):
476-
terminationStatus = .success(status)
482+
let terminationResult: Result<(TerminationStatus, ResourceUsage), SubprocessError>
483+
switch Result(catching: { () throws(Errno) -> (TerminationStatus, ResourceUsage)? in try _reap(pid: knownChildPID) }) {
484+
case let .success(result?):
485+
terminationResult = .success(result)
477486
case .success(nil):
478487
// Move on to the next child
479488
continue loop
480489
case let .failure(error):
481-
terminationStatus = .failure(
490+
terminationResult = .failure(
482491
SubprocessError.failedToMonitor(withUnderlyingError: error)
483492
)
484493
}
485-
results.append((result: terminationStatus, continuation: continuation))
494+
results.append((result: terminationResult, continuation: continuation))
486495
// Now we have the exit code, remove saved continuations
487496
updatedContinuations.removeValue(forKey: knownChildPID)
488497
}

Sources/Subprocess/Platforms/Subprocess+Unix.swift

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -687,37 +687,63 @@ extension PlatformOptions: CustomStringConvertible, CustomDebugStringConvertible
687687
extension ProcessIdentifier {
688688
/// Reaps the zombie for the exited process. This function may block.
689689
@available(*, noasync)
690-
internal func blockingReap() throws(Errno) -> TerminationStatus {
690+
internal func blockingReap() throws(Errno) -> (TerminationStatus, ResourceUsage) {
691691
try _blockingReap(pid: value)
692692
}
693693

694694
/// Reaps the zombie for the exited process, or returns `nil` if the process is still running. This function will not block.
695-
internal func reap() throws(Errno) -> TerminationStatus? {
695+
internal func reap() throws(Errno) -> (TerminationStatus, ResourceUsage)? {
696696
try _reap(pid: value)
697697
}
698698
}
699699

700700
@available(*, noasync)
701-
internal func _blockingReap(pid: pid_t) throws(Errno) -> TerminationStatus {
702-
let siginfo = try _waitid(idtype: P_PID, id: id_t(pid), flags: WEXITED)
703-
return TerminationStatus(siginfo)
704-
}
705-
706-
internal func _reap(pid: pid_t) throws(Errno) -> TerminationStatus? {
707-
let siginfo = try _waitid(idtype: P_PID, id: id_t(pid), flags: WEXITED | WNOHANG)
708-
// If si_pid and si_signo are both 0, the child is still running since we used WNOHANG
709-
if siginfo.si_pid == 0 && siginfo.si_signo == 0 {
710-
return nil
701+
internal func _blockingReap(pid: pid_t) throws(Errno) -> (TerminationStatus, ResourceUsage) {
702+
while true {
703+
var usage = rusage()
704+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
705+
var status: CInt = 0
706+
let rc = wait4(pid, &status, 0, &usage)
707+
#elseif os(Linux) || os(Android)
708+
var siginfo = siginfo_t()
709+
let rc = linux_waitid(P_PID, id_t(pid), &siginfo, WEXITED, &usage)
710+
#endif
711+
if rc >= 0 {
712+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
713+
return (TerminationStatus(waitStatus: status), ResourceUsage(usage))
714+
#elseif os(Linux) || os(Android)
715+
return (TerminationStatus(siginfo), ResourceUsage(usage))
716+
#endif
717+
} else if errno != EINTR {
718+
throw Errno(rawValue: errno)
719+
}
711720
}
712-
return TerminationStatus(siginfo)
713721
}
714722

715-
internal func _waitid(idtype: idtype_t, id: id_t, flags: Int32) throws(Errno) -> siginfo_t {
723+
internal func _reap(pid: pid_t) throws(Errno) -> (TerminationStatus, ResourceUsage)? {
716724
while true {
725+
var usage = rusage()
726+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
727+
var status: CInt = 0
728+
let rc = wait4(pid, &status, WNOHANG, &usage)
729+
if rc > 0 {
730+
return (TerminationStatus(waitStatus: status), ResourceUsage(usage))
731+
} else if rc == 0 {
732+
return nil // Child still running
733+
}
734+
#elseif os(Linux) || os(Android)
717735
var siginfo = siginfo_t()
718-
if waitid(idtype, id, &siginfo, flags) != -1 {
719-
return siginfo
720-
} else if errno != EINTR {
736+
let rc = linux_waitid(P_PID, id_t(pid), &siginfo, WEXITED | WNOHANG, &usage)
737+
if rc != -1 {
738+
// If si_pid and si_signo are both 0, the child is still running since we used WNOHANG
739+
if siginfo.si_pid == 0 && siginfo.si_signo == 0 {
740+
return nil
741+
}
742+
return (TerminationStatus(siginfo), ResourceUsage(usage))
743+
}
744+
#endif
745+
// rc == -1: either EINTR (retry) or a real error
746+
if errno != EINTR {
721747
throw Errno(rawValue: errno)
722748
}
723749
}
@@ -736,6 +762,20 @@ internal extension TerminationStatus {
736762
}
737763
}
738764

765+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
766+
internal extension TerminationStatus {
767+
init(waitStatus: CInt) {
768+
if _was_process_exited(waitStatus) != 0 {
769+
self = .exited(CInt(_get_exit_code(waitStatus)))
770+
} else if _was_process_signaled(waitStatus) != 0 {
771+
self = .signaled(CInt(_get_signal_code(waitStatus)))
772+
} else {
773+
fatalError("Unexpected wait status: \(waitStatus)")
774+
}
775+
}
776+
}
777+
#endif
778+
739779
#if os(OpenBSD) || os(Linux) || os(Android)
740780
internal extension siginfo_t {
741781
var si_status: Int32 {

Sources/Subprocess/Platforms/Subprocess+Windows.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ extension PlatformOptions: CustomStringConvertible, CustomDebugStringConvertible
603603
@Sendable
604604
internal func monitorProcessTermination(
605605
for processIdentifier: ProcessIdentifier
606-
) async throws(SubprocessError) -> TerminationStatus {
606+
) async throws(SubprocessError) -> (TerminationStatus, ResourceUsage) {
607607
// Once the continuation resumes, it will need to unregister the wait, so
608608
// yield the wait handle back to the calling scope.
609609
var waitHandle: HANDLE?
@@ -648,14 +648,17 @@ internal func monitorProcessTermination(
648648
}
649649
}
650650

651+
// Collect resource usage while the process handle is still valid
652+
let resourceUsage = ResourceUsage(processHandle: processIdentifier.processDescriptor)
653+
651654
var status: DWORD = 0
652655
guard GetExitCodeProcess(processIdentifier.processDescriptor, &status) else {
653656
throw SubprocessError.failedToMonitor(
654657
withUnderlyingError: .init(rawValue: GetLastError())
655658
)
656659
}
657660

658-
return .exited(status)
661+
return (.exited(status), resourceUsage)
659662
}
660663

661664
// MARK: - Subprocess Control

0 commit comments

Comments
 (0)