Skip to content

Commit ed3aa46

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. # Conflicts: # Sources/Subprocess/Platforms/Subprocess+Windows.swift
1 parent bcac775 commit ed3aa46

10 files changed

Lines changed: 261 additions & 61 deletions

File tree

Sources/Subprocess/API.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ public func run<
464464
return ExecutionRecord(
465465
processIdentifier: result.value.processIdentifier,
466466
terminationStatus: result.terminationStatus,
467+
resourceUsage: result.resourceUsage,
467468
standardOutput: result.value.standardOutput,
468469
standardError: result.value.standardError
469470
)
@@ -567,6 +568,7 @@ public func run<
567568
return ExecutionRecord(
568569
processIdentifier: result.value.processIdentifier,
569570
terminationStatus: result.terminationStatus,
571+
resourceUsage: result.resourceUsage,
570572
standardOutput: result.value.standardOutput,
571573
standardError: result.value.standardError
572574
)

Sources/Subprocess/Configuration.swift

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

140140
return ExecutionOutcome(
141141
terminationStatus: terminationStatus,
142+
resourceUsage: resourceUsage,
142143
value: try result.get()
143144
)
144145
} 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
@@ -676,37 +676,63 @@ extension PlatformOptions: CustomStringConvertible, CustomDebugStringConvertible
676676
extension ProcessIdentifier {
677677
/// Reaps the zombie for the exited process. This function may block.
678678
@available(*, noasync)
679-
internal func blockingReap() throws(Errno) -> TerminationStatus {
679+
internal func blockingReap() throws(Errno) -> (TerminationStatus, ResourceUsage) {
680680
try _blockingReap(pid: value)
681681
}
682682

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

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

704-
internal func _waitid(idtype: idtype_t, id: id_t, flags: Int32) throws(Errno) -> siginfo_t {
712+
internal func _reap(pid: pid_t) throws(Errno) -> (TerminationStatus, ResourceUsage)? {
705713
while true {
714+
var usage = rusage()
715+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
716+
var status: CInt = 0
717+
let rc = wait4(pid, &status, WNOHANG, &usage)
718+
if rc > 0 {
719+
return (TerminationStatus(waitStatus: status), ResourceUsage(usage))
720+
} else if rc == 0 {
721+
return nil // Child still running
722+
}
723+
#elseif os(Linux) || os(Android)
706724
var siginfo = siginfo_t()
707-
if waitid(idtype, id, &siginfo, flags) != -1 {
708-
return siginfo
709-
} else if errno != EINTR {
725+
let rc = linux_waitid(P_PID, id_t(pid), &siginfo, WEXITED | WNOHANG, &usage)
726+
if rc != -1 {
727+
// If si_pid and si_signo are both 0, the child is still running since we used WNOHANG
728+
if siginfo.si_pid == 0 && siginfo.si_signo == 0 {
729+
return nil
730+
}
731+
return (TerminationStatus(siginfo), ResourceUsage(usage))
732+
}
733+
#endif
734+
// rc == -1: either EINTR (retry) or a real error
735+
if errno != EINTR {
710736
throw Errno(rawValue: errno)
711737
}
712738
}
@@ -725,6 +751,20 @@ internal extension TerminationStatus {
725751
}
726752
}
727753

754+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
755+
internal extension TerminationStatus {
756+
init(waitStatus: CInt) {
757+
if _was_process_exited(waitStatus) != 0 {
758+
self = .exited(CInt(_get_exit_code(waitStatus)))
759+
} else if _was_process_signaled(waitStatus) != 0 {
760+
self = .signaled(CInt(_get_signal_code(waitStatus)))
761+
} else {
762+
fatalError("Unexpected wait status: \(waitStatus)")
763+
}
764+
}
765+
}
766+
#endif
767+
728768
#if os(OpenBSD) || os(Linux) || os(Android)
729769
internal extension siginfo_t {
730770
var si_status: Int32 {

Sources/Subprocess/Platforms/Subprocess+Windows.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ extension PlatformOptions: CustomStringConvertible, CustomDebugStringConvertible
610610
@Sendable
611611
internal func monitorProcessTermination(
612612
for processIdentifier: ProcessIdentifier
613-
) async throws(SubprocessError) -> TerminationStatus {
613+
) async throws(SubprocessError) -> (TerminationStatus, ResourceUsage) {
614614
// Once the continuation resumes, it will need to unregister the wait, so
615615
// yield the wait handle back to the calling scope.
616616
var waitHandle: HANDLE?
@@ -655,14 +655,17 @@ internal func monitorProcessTermination(
655655
}
656656
}
657657

658+
// Collect resource usage while the process handle is still valid
659+
let resourceUsage = ResourceUsage(processHandle: processIdentifier.processDescriptor)
660+
658661
var status: DWORD = 0
659662
guard GetExitCodeProcess(processIdentifier.processDescriptor, &status) else {
660663
throw SubprocessError.failedToMonitor(
661664
withUnderlyingError: .init(rawValue: GetLastError())
662665
)
663666
}
664667

665-
return .exited(status)
668+
return (.exited(status), resourceUsage)
666669
}
667670

668671
// MARK: - Subprocess Control

0 commit comments

Comments
 (0)