Skip to content

Commit 66ae4c3

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 7928f39 commit 66ae4c3

11 files changed

Lines changed: 272 additions & 66 deletions

File tree

Sources/Subprocess/API.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ public func run<
440440
return ExecutionRecord(
441441
processIdentifier: result.value.processIdentifier,
442442
terminationStatus: result.terminationStatus,
443+
resourceUsage: result.resourceUsage,
443444
standardOutput: result.value.standardOutput,
444445
standardError: result.value.standardError
445446
)
@@ -544,6 +545,7 @@ public func run<
544545
return ExecutionRecord(
545546
processIdentifier: result.value.processIdentifier,
546547
terminationStatus: result.terminationStatus,
548+
resourceUsage: result.resourceUsage,
547549
standardOutput: result.value.standardOutput,
548550
standardError: result.value.standardError
549551
)

Sources/Subprocess/Configuration.swift

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

141141
return ExecutionOutcome(
142142
terminationStatus: terminationStatus,
143+
resourceUsage: resourceUsage,
143144
value: try result.get()
144145
)
145146
} 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
@@ -79,10 +79,10 @@ extension Int32 {
7979
@Sendable
8080
internal func monitorProcessTermination(
8181
for processIdentifier: ProcessIdentifier
82-
) async throws(SubprocessError) -> TerminationStatus {
82+
) async throws(SubprocessError) -> (TerminationStatus, ResourceUsage) {
8383
return try await _castError {
8484
return try await withCheckedThrowingContinuation { continuation in
85-
let status = _processMonitorState.withLock { state -> Result<TerminationStatus, SubprocessError>? in
85+
let status = _processMonitorState.withLock { state -> Result<(TerminationStatus, ResourceUsage), SubprocessError>? in
8686
switch state {
8787
case .notStarted:
8888
let error: SubprocessError = .failedToMonitor(withUnderlyingError: nil)
@@ -123,9 +123,9 @@ internal func monitorProcessTermination(
123123
// Since Linux coalesce signals, it's possible by the time we request
124124
// monitoring the process has already exited. Check to make sure that
125125
// is not the case and only save continuation then.
126-
switch Result(catching: { () throws(Errno) -> TerminationStatus? in try processIdentifier.reap() }) {
127-
case let .success(status?):
128-
return .success(status)
126+
switch Result(catching: { () throws(Errno) -> (TerminationStatus, ResourceUsage)? in try processIdentifier.reap() }) {
127+
case let .success(result?):
128+
return .success(result)
129129
case .success(nil):
130130
// Save this continuation to be called by signal handler
131131
var newState = storage
@@ -158,7 +158,7 @@ private enum ProcessMonitorState {
158158
let epollFileDescriptor: CInt
159159
let shutdownFileDescriptor: CInt
160160
let monitorThread: pthread_t
161-
var continuations: [PlatformFileDescriptor: CheckedContinuation<TerminationStatus, any Error>]
161+
var continuations: [PlatformFileDescriptor: CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>]
162162
}
163163

164164
case notStarted
@@ -243,8 +243,8 @@ private func monitorThreadFunc(context: MonitorThreadContext) {
243243
let error: SubprocessError = .failedToMonitor(
244244
withUnderlyingError: Errno(rawValue: pwaitErrno)
245245
)
246-
let continuations = _processMonitorState.withLock { state -> [CheckedContinuation<TerminationStatus, any Error>] in
247-
let result: [CheckedContinuation<TerminationStatus, any Error>]
246+
let continuations = _processMonitorState.withLock { state -> [CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>] in
247+
let result: [CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>]
248248
if case .started(let storage) = state {
249249
result = Array(storage.continuations.values)
250250
} else {
@@ -407,8 +407,17 @@ internal func _setupMonitorSignalHandler() {
407407
}
408408

409409
private func _blockAndWaitForProcessDescriptor(_ pidfd: CInt, context: MonitorThreadContext) {
410-
var terminationStatus = Result(catching: { () throws(Errno) in
411-
try TerminationStatus(_waitid(idtype: idtype_t(UInt32(P_PIDFD)), id: id_t(pidfd), flags: WEXITED))
410+
var terminationResult = Result(catching: { () throws(Errno) -> (TerminationStatus, ResourceUsage) in
411+
while true {
412+
var siginfo = siginfo_t()
413+
var usage = rusage()
414+
let rc = linux_waitid(idtype_t(UInt32(P_PIDFD)), id_t(pidfd), &siginfo, WEXITED, &usage)
415+
if rc != -1 {
416+
return (TerminationStatus(siginfo), ResourceUsage(usage))
417+
} else if errno != EINTR {
418+
throw Errno(rawValue: errno)
419+
}
420+
}
412421
}).mapError { underlyingError in
413422
return SubprocessError.failedToMonitor(withUnderlyingError: underlyingError)
414423
}
@@ -422,14 +431,14 @@ private func _blockAndWaitForProcessDescriptor(_ pidfd: CInt, context: MonitorTh
422431
)
423432
if rc != 0 {
424433
let epollErrno = errno
425-
terminationStatus = .failure(
434+
terminationResult = .failure(
426435
SubprocessError.failedToMonitor(
427436
withUnderlyingError: Errno(rawValue: epollErrno)
428437
)
429438
)
430439
}
431440
// Notify the continuation
432-
let continuation = _processMonitorState.withLock { state -> CheckedContinuation<TerminationStatus, any Error>? in
441+
let continuation = _processMonitorState.withLock { state -> CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>? in
433442
guard case .started(let storage) = state,
434443
let continuation = storage.continuations[pidfd]
435444
else {
@@ -441,13 +450,13 @@ private func _blockAndWaitForProcessDescriptor(_ pidfd: CInt, context: MonitorTh
441450
state = .started(newStorage)
442451
return continuation
443452
}
444-
continuation?.resume(with: terminationStatus)
453+
continuation?.resume(with: terminationResult)
445454
}
446455

447456
// On older kernels, fall back to using signal handlers
448457
private typealias ResultContinuation = (
449-
result: Result<TerminationStatus, SubprocessError>,
450-
continuation: CheckedContinuation<TerminationStatus, any Error>
458+
result: Result<(TerminationStatus, ResourceUsage), SubprocessError>,
459+
continuation: CheckedContinuation<(TerminationStatus, ResourceUsage), any Error>
451460
)
452461
private func _reapAllKnownChildProcesses(_ signalFd: CInt, context: MonitorThreadContext) {
453462
guard signalFd == _signalPipe.readEnd else {
@@ -467,19 +476,19 @@ private func _reapAllKnownChildProcesses(_ signalFd: CInt, context: MonitorThrea
467476
// Since Linux coalesce signals, we need to loop through all known child process
468477
// to check if they exited.
469478
loop: for (knownChildPID, continuation) in storage.continuations {
470-
let terminationStatus: Result<TerminationStatus, SubprocessError>
471-
switch Result(catching: { () throws(Errno) -> TerminationStatus? in try _reap(pid: knownChildPID) }) {
472-
case let .success(status?):
473-
terminationStatus = .success(status)
479+
let terminationResult: Result<(TerminationStatus, ResourceUsage), SubprocessError>
480+
switch Result(catching: { () throws(Errno) -> (TerminationStatus, ResourceUsage)? in try _reap(pid: knownChildPID) }) {
481+
case let .success(result?):
482+
terminationResult = .success(result)
474483
case .success(nil):
475484
// Move on to the next child
476485
continue loop
477486
case let .failure(error):
478-
terminationStatus = .failure(
487+
terminationResult = .failure(
479488
SubprocessError.failedToMonitor(withUnderlyingError: error)
480489
)
481490
}
482-
results.append((result: terminationStatus, continuation: continuation))
491+
results.append((result: terminationResult, continuation: continuation))
483492
// Now we have the exit code, remove saved continuations
484493
updatedContinuations.removeValue(forKey: knownChildPID)
485494
}

Sources/Subprocess/Platforms/Subprocess+Unix.swift

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -673,37 +673,63 @@ extension PlatformOptions: CustomStringConvertible, CustomDebugStringConvertible
673673
extension ProcessIdentifier {
674674
/// Reaps the zombie for the exited process. This function may block.
675675
@available(*, noasync)
676-
internal func blockingReap() throws(Errno) -> TerminationStatus {
676+
internal func blockingReap() throws(Errno) -> (TerminationStatus, ResourceUsage) {
677677
try _blockingReap(pid: value)
678678
}
679679

680680
/// Reaps the zombie for the exited process, or returns `nil` if the process is still running. This function will not block.
681-
internal func reap() throws(Errno) -> TerminationStatus? {
681+
internal func reap() throws(Errno) -> (TerminationStatus, ResourceUsage)? {
682682
try _reap(pid: value)
683683
}
684684
}
685685

686686
@available(*, noasync)
687-
internal func _blockingReap(pid: pid_t) throws(Errno) -> TerminationStatus {
688-
let siginfo = try _waitid(idtype: P_PID, id: id_t(pid), flags: WEXITED)
689-
return TerminationStatus(siginfo)
690-
}
691-
692-
internal func _reap(pid: pid_t) throws(Errno) -> TerminationStatus? {
693-
let siginfo = try _waitid(idtype: P_PID, id: id_t(pid), flags: WEXITED | WNOHANG)
694-
// If si_pid and si_signo are both 0, the child is still running since we used WNOHANG
695-
if siginfo.si_pid == 0 && siginfo.si_signo == 0 {
696-
return nil
687+
internal func _blockingReap(pid: pid_t) throws(Errno) -> (TerminationStatus, ResourceUsage) {
688+
while true {
689+
var usage = rusage()
690+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
691+
var status: CInt = 0
692+
let rc = wait4(pid, &status, 0, &usage)
693+
#elseif os(Linux) || os(Android)
694+
var siginfo = siginfo_t()
695+
let rc = linux_waitid(P_PID, id_t(pid), &siginfo, WEXITED, &usage)
696+
#endif
697+
if rc >= 0 {
698+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
699+
return (TerminationStatus(waitStatus: status), ResourceUsage(usage))
700+
#elseif os(Linux) || os(Android)
701+
return (TerminationStatus(siginfo), ResourceUsage(usage))
702+
#endif
703+
} else if errno != EINTR {
704+
throw Errno(rawValue: errno)
705+
}
697706
}
698-
return TerminationStatus(siginfo)
699707
}
700708

701-
internal func _waitid(idtype: idtype_t, id: id_t, flags: Int32) throws(Errno) -> siginfo_t {
709+
internal func _reap(pid: pid_t) throws(Errno) -> (TerminationStatus, ResourceUsage)? {
702710
while true {
711+
var usage = rusage()
712+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
713+
var status: CInt = 0
714+
let rc = wait4(pid, &status, WNOHANG, &usage)
715+
if rc > 0 {
716+
return (TerminationStatus(waitStatus: status), ResourceUsage(usage))
717+
} else if rc == 0 {
718+
return nil // Child still running
719+
}
720+
#elseif os(Linux) || os(Android)
703721
var siginfo = siginfo_t()
704-
if waitid(idtype, id, &siginfo, flags) != -1 {
705-
return siginfo
706-
} else if errno != EINTR {
722+
let rc = linux_waitid(P_PID, id_t(pid), &siginfo, WEXITED | WNOHANG, &usage)
723+
if rc != -1 {
724+
// If si_pid and si_signo are both 0, the child is still running since we used WNOHANG
725+
if siginfo.si_pid == 0 && siginfo.si_signo == 0 {
726+
return nil
727+
}
728+
return (TerminationStatus(siginfo), ResourceUsage(usage))
729+
}
730+
#endif
731+
// rc == -1: either EINTR (retry) or a real error
732+
if errno != EINTR {
707733
throw Errno(rawValue: errno)
708734
}
709735
}
@@ -722,6 +748,20 @@ internal extension TerminationStatus {
722748
}
723749
}
724750

751+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
752+
internal extension TerminationStatus {
753+
init(waitStatus: CInt) {
754+
if _was_process_exited(waitStatus) != 0 {
755+
self = .exited(CInt(_get_exit_code(waitStatus)))
756+
} else if _was_process_signaled(waitStatus) != 0 {
757+
self = .unhandledException(CInt(_get_signal_code(waitStatus)))
758+
} else {
759+
fatalError("Unexpected wait status: \(waitStatus)")
760+
}
761+
}
762+
}
763+
#endif
764+
725765
#if os(OpenBSD) || os(Linux) || os(Android)
726766
internal extension siginfo_t {
727767
var si_status: Int32 {

Sources/Subprocess/Platforms/Subprocess+Windows.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ extension PlatformOptions: CustomStringConvertible, CustomDebugStringConvertible
605605
@Sendable
606606
internal func monitorProcessTermination(
607607
for processIdentifier: ProcessIdentifier
608-
) async throws(SubprocessError) -> TerminationStatus {
608+
) async throws(SubprocessError) -> (TerminationStatus, ResourceUsage) {
609609
// Once the continuation resumes, it will need to unregister the wait, so
610610
// yield the wait handle back to the calling scope.
611611
var waitHandle: HANDLE?
@@ -650,17 +650,20 @@ internal func monitorProcessTermination(
650650
}
651651
}
652652

653+
// Collect resource usage while the process handle is still valid
654+
let resourceUsage = ResourceUsage(processHandle: processIdentifier.processDescriptor)
655+
653656
var status: DWORD = 0
654657
guard GetExitCodeProcess(processIdentifier.processDescriptor, &status) else {
655658
// The child process terminated but we couldn't get its status back.
656659
// Assume generic failure.
657-
return .exited(1)
660+
return (.exited(1), resourceUsage)
658661
}
659662
let exitCodeValue = CInt(bitPattern: .init(status))
660663
guard exitCodeValue >= 0 else {
661-
return .unhandledException(status)
664+
return (.unhandledException(status), resourceUsage)
662665
}
663-
return .exited(status)
666+
return (.exited(status), resourceUsage)
664667
}
665668

666669
// MARK: - Subprocess Control

0 commit comments

Comments
 (0)