Skip to content

Commit 006121c

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 370f301 commit 006121c

8 files changed

Lines changed: 236 additions & 31 deletions

File tree

Sources/Subprocess/API.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ public func run<
378378
return ExecutionResult(
379379
processIdentifier: result.value.processIdentifier,
380380
terminationStatus: result.terminationStatus,
381+
resourceUsage: result.resourceUsage,
381382
closureOutput: result.value.closureResult,
382383
standardOutput: result.value.output,
383384
standardError: result.value.error

Sources/Subprocess/Configuration.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,15 @@ public struct Configuration: Sendable {
162162
// stale entry and reject its registrations.
163163
AsyncIO.shared.cleanup(processIdentifier: processIdentifier)
164164

165-
let terminationStatus = try reapProcess(with: processIdentifier)
165+
let (terminationStatus, resourceUsage) = try reapProcess(with: processIdentifier)
166166

167167
if let monitorError {
168168
throw monitorError
169169
}
170170

171171
return try ExecutionOutcome(
172172
terminationStatus: terminationStatus,
173+
resourceUsage: resourceUsage,
173174
value: result.get()
174175
)
175176
} onCleanup: {

Sources/Subprocess/Platforms/Subprocess+Unix.swift

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -694,14 +694,13 @@ extension PlatformOptions: CustomStringConvertible, CustomDebugStringConvertible
694694
@Sendable
695695
internal func reapProcess(
696696
with processIdentifier: ProcessIdentifier
697-
) throws(SubprocessError) -> TerminationStatus {
697+
) throws(SubprocessError) -> (TerminationStatus, ResourceUsage) {
698698
do throws(Errno) {
699699
// On some platforms, the process exit notification (in particular NOTE_EXIT from kqueue)
700700
// may be delivered slightly before the process becomes reapable,
701701
// so we must call waitid without WNOHANG to avoid a narrow possibility of a race condition.
702702
// If waitid does block, it won't do so for very long at all.
703-
let status = try processIdentifier.blockingReap()
704-
return status
703+
return try processIdentifier.blockingReap()
705704
} catch {
706705
let subprocessError: SubprocessError = .failedToMonitor(withUnderlyingError: error)
707706
throw subprocessError
@@ -711,12 +710,12 @@ internal func reapProcess(
711710
extension ProcessIdentifier {
712711
/// Reaps the zombie for the exited process. This function may block.
713712
@available(*, noasync)
714-
internal func blockingReap() throws(Errno) -> TerminationStatus {
713+
internal func blockingReap() throws(Errno) -> (TerminationStatus, ResourceUsage) {
715714
try _blockingReap(pid: value)
716715
}
717716

718717
/// Reaps the zombie for the exited process, or returns `nil` if the process is still running. This function will not block.
719-
internal func reap() throws(Errno) -> TerminationStatus? {
718+
internal func reap() throws(Errno) -> (TerminationStatus, ResourceUsage)? {
720719
try _reap(pid: value)
721720
}
722721

@@ -730,18 +729,55 @@ extension ProcessIdentifier {
730729
}
731730

732731
@available(*, noasync)
733-
internal func _blockingReap(pid: pid_t) throws(Errno) -> TerminationStatus {
734-
let siginfo = try _waitid(idtype: P_PID, id: id_t(pid), flags: WEXITED)
735-
return TerminationStatus(siginfo)
732+
internal func _blockingReap(pid: pid_t) throws(Errno) -> (TerminationStatus, ResourceUsage) {
733+
while true {
734+
var usage = rusage()
735+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
736+
var status: CInt = 0
737+
let rc = wait4(pid, &status, 0, &usage)
738+
#elseif os(Linux) || os(Android)
739+
var siginfo = siginfo_t()
740+
let rc = linux_waitid(P_PID, id_t(pid), &siginfo, WEXITED, &usage)
741+
#endif
742+
if rc >= 0 {
743+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
744+
return (TerminationStatus(waitStatus: status), ResourceUsage(usage))
745+
#elseif os(Linux) || os(Android)
746+
return (TerminationStatus(siginfo), ResourceUsage(usage))
747+
#endif
748+
} else if errno != EINTR {
749+
throw Errno(rawValue: errno)
750+
}
751+
}
736752
}
737753

738-
internal func _reap(pid: pid_t) throws(Errno) -> TerminationStatus? {
739-
let siginfo = try _waitid(idtype: P_PID, id: id_t(pid), flags: WEXITED | WNOHANG)
740-
// If si_pid and si_signo are both 0, the child is still running since we used WNOHANG
741-
if siginfo.si_pid == 0 && siginfo.si_signo == 0 {
742-
return nil
754+
internal func _reap(pid: pid_t) throws(Errno) -> (TerminationStatus, ResourceUsage)? {
755+
while true {
756+
var usage = rusage()
757+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
758+
var status: CInt = 0
759+
let rc = wait4(pid, &status, WNOHANG, &usage)
760+
if rc > 0 {
761+
return (TerminationStatus(waitStatus: status), ResourceUsage(usage))
762+
} else if rc == 0 {
763+
return nil // Child still running
764+
}
765+
#elseif os(Linux) || os(Android)
766+
var siginfo = siginfo_t()
767+
let rc = linux_waitid(P_PID, id_t(pid), &siginfo, WEXITED | WNOHANG, &usage)
768+
if rc != -1 {
769+
// If si_pid and si_signo are both 0, the child is still running since we used WNOHANG
770+
if siginfo.si_pid == 0 && siginfo.si_signo == 0 {
771+
return nil
772+
}
773+
return (TerminationStatus(siginfo), ResourceUsage(usage))
774+
}
775+
#endif
776+
// rc == -1: either EINTR (retry) or a real error
777+
if errno != EINTR {
778+
throw Errno(rawValue: errno)
779+
}
743780
}
744-
return TerminationStatus(siginfo)
745781
}
746782

747783
internal func _peekIfExited(pid: pid_t) throws(Errno) -> Bool {
@@ -775,6 +811,21 @@ internal extension TerminationStatus {
775811
}
776812
}
777813

814+
#if os(macOS) || os(FreeBSD) || os(OpenBSD)
815+
internal extension TerminationStatus {
816+
init(waitStatus: CInt) {
817+
switch (_was_process_exited(waitStatus) != 0, _was_process_signaled(waitStatus) != 0) {
818+
case (true, false):
819+
self = .exited(CInt(_get_exit_code(waitStatus)))
820+
case (false, true):
821+
self = .signaled(CInt(_get_signal_code(waitStatus)))
822+
case (true, true), (false, false):
823+
fatalError("Unexpected wait status: \(waitStatus)")
824+
}
825+
}
826+
}
827+
#endif
828+
778829
#if os(OpenBSD) || os(Linux) || os(Android)
779830
internal extension siginfo_t {
780831
var si_status: Int32 {

Sources/Subprocess/Platforms/Subprocess+Windows.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,18 +575,21 @@ internal func waitForProcessTermination(
575575
@Sendable
576576
internal func reapProcess(
577577
with processIdentifier: ProcessIdentifier
578-
) throws(SubprocessError) -> TerminationStatus {
578+
) throws(SubprocessError) -> (TerminationStatus, ResourceUsage) {
579579
// Windows keeps the exit code reachable through the process HANDLE
580580
// until the HANDLE is closed, so there is no zombie to reap. We just
581581
// need to read the exit code via `GetExitCodeProcess`.
582+
// Collect resource usage while the process handle is still valid
583+
let resourceUsage = ResourceUsage(processHandle: processIdentifier.processDescriptor)
584+
582585
var status: DWORD = 0
583586
guard GetExitCodeProcess(processIdentifier.processDescriptor, &status) else {
584587
throw SubprocessError.failedToMonitor(
585588
withUnderlyingError: .init(rawValue: GetLastError())
586589
)
587590
}
588591

589-
return .exited(status)
592+
return (.exited(status), resourceUsage)
590593
}
591594

592595
// MARK: - Subprocess Control

Sources/Subprocess/Result.swift

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,115 @@ import System
1515
import SystemPackage
1616
#endif
1717

18+
#if canImport(Darwin)
19+
public import Darwin
20+
#elseif canImport(Glibc)
21+
public import Glibc
22+
#elseif canImport(Musl)
23+
public import Musl
24+
#elseif canImport(Android)
25+
public import Android
26+
#elseif canImport(WinSDK)
27+
import WinSDK
28+
#endif
29+
30+
// MARK: - ResourceUsage
31+
32+
/// Resource usage information for a terminated subprocess.
33+
public struct ResourceUsage: Sendable, Hashable {
34+
/// The total amount of time spent executing in user mode.
35+
public let userTime: Duration
36+
/// The total amount of time spent executing in kernel mode.
37+
public let systemTime: Duration
38+
/// The peak resident set size (maximum memory used), in bytes.
39+
public let maxRSS: Int
40+
41+
#if !os(Windows)
42+
/// The underlying POSIX resource usage information.
43+
public let rusage: rusage
44+
#endif
45+
}
46+
47+
extension ResourceUsage {
48+
#if os(Windows)
49+
internal init(processHandle: HANDLE) {
50+
var creationTime = FILETIME()
51+
var exitTime = FILETIME()
52+
var kernelTime = FILETIME()
53+
var userFileTime = FILETIME()
54+
55+
if GetProcessTimes(
56+
processHandle,
57+
&creationTime,
58+
&exitTime,
59+
&kernelTime,
60+
&userFileTime
61+
) {
62+
self.userTime = Duration(userFileTime)
63+
self.systemTime = Duration(kernelTime)
64+
} else {
65+
self.userTime = .zero
66+
self.systemTime = .zero
67+
}
68+
69+
var memInfo = PROCESS_MEMORY_COUNTERS()
70+
memInfo.cb = DWORD(MemoryLayout<PROCESS_MEMORY_COUNTERS>.size)
71+
if K32GetProcessMemoryInfo(
72+
processHandle,
73+
&memInfo,
74+
DWORD(MemoryLayout<PROCESS_MEMORY_COUNTERS>.size)
75+
) {
76+
self.maxRSS = Int(memInfo.PeakWorkingSetSize)
77+
} else {
78+
self.maxRSS = 0
79+
}
80+
}
81+
#else
82+
internal init(_ usage: rusage) {
83+
self.userTime = Duration(
84+
secondsComponent: Int64(usage.ru_utime.tv_sec),
85+
attosecondsComponent: Int64(usage.ru_utime.tv_usec) * 1_000_000_000_000
86+
)
87+
self.systemTime = Duration(
88+
secondsComponent: Int64(usage.ru_stime.tv_sec),
89+
attosecondsComponent: Int64(usage.ru_stime.tv_usec) * 1_000_000_000_000
90+
)
91+
#if canImport(Darwin)
92+
self.maxRSS = Int(usage.ru_maxrss) // bytes on Darwin
93+
#elseif os(Linux) || os(Android) || os(FreeBSD) || os(OpenBSD)
94+
self.maxRSS = Int(usage.ru_maxrss) * 1024 // KiB to bytes (Linux, FreeBSD, OpenBSD, NetBSD)
95+
#else
96+
#error("ru_maxrss unit scaling not defined for this platform")
97+
#endif
98+
self.rusage = usage
99+
}
100+
#endif
101+
}
102+
103+
#if os(Windows)
104+
extension Duration {
105+
fileprivate init(_ ft: FILETIME) {
106+
let hundredNanos = UInt64(ft.dwHighDateTime) << 32 | UInt64(ft.dwLowDateTime)
107+
let seconds = Int64(hundredNanos / 10_000_000)
108+
let remainder = Int64(hundredNanos % 10_000_000)
109+
self = Duration(
110+
secondsComponent: seconds,
111+
attosecondsComponent: remainder * 100_000_000_000
112+
)
113+
}
114+
}
115+
#endif
116+
117+
// MARK: - ExecutionSummary Protocol
118+
119+
/// Protocol providing common properties for subprocess execution results.
120+
public protocol ExecutionSummary: Sendable {
121+
/// The termination status of the child process.
122+
var terminationStatus: TerminationStatus { get }
123+
/// The resource usage of the terminated child process.
124+
var resourceUsage: ResourceUsage { get }
125+
}
126+
18127
// MARK: - Result
19128

20129
/// The result of running a subprocess, including the closure's return value,
@@ -42,27 +151,54 @@ public struct ExecutionResult<
42151
public let standardOutput: Output.OutputType
43152
/// The collected standard error of the subprocess.
44153
public let standardError: Error.OutputType
154+
/// The resource usage of the terminated child process.
155+
public let resourceUsage: ResourceUsage
45156

46157
/// The value returned by the body closure passed to `run`.
47158
public let closureOutput: ClosureResult
48159

49160
internal init(
50161
processIdentifier: ProcessIdentifier,
51162
terminationStatus: TerminationStatus,
163+
resourceUsage: ResourceUsage,
52164
closureOutput: ClosureResult,
53165
standardOutput: Output.OutputType,
54166
standardError: Error.OutputType
55167
) {
56168
self.processIdentifier = processIdentifier
57169
self.terminationStatus = terminationStatus
170+
self.resourceUsage = resourceUsage
58171
self.closureOutput = closureOutput
59172
self.standardOutput = standardOutput
60173
self.standardError = standardError
61174
}
62175
}
63176

177+
// MARK: - rusage Conformances
178+
#if !os(Windows)
179+
extension rusage: @retroactive Equatable {
180+
public static func == (lhs: rusage, rhs: rusage) -> Bool {
181+
withUnsafeBytes(of: lhs) { lhsBytes in
182+
withUnsafeBytes(of: rhs) { rhsBytes in
183+
lhsBytes.elementsEqual(rhsBytes)
184+
}
185+
}
186+
}
187+
}
188+
189+
extension rusage: @retroactive Hashable {
190+
public func hash(into hasher: inout Hasher) {
191+
withUnsafeBytes(of: self) { bytes in
192+
hasher.combine(bytes: bytes)
193+
}
194+
}
195+
}
196+
#endif
197+
64198
// MARK: - ExecutionResult Conformances
65199

200+
extension ExecutionResult: ExecutionSummary {}
201+
66202
extension ExecutionResult: Equatable where Output.OutputType: Equatable, Error.OutputType: Equatable, ClosureResult: Equatable {}
67203

68204
extension ExecutionResult: Hashable where Output.OutputType: Hashable, Error.OutputType: Hashable, ClosureResult: Hashable {}
@@ -74,6 +210,7 @@ extension ExecutionResult: CustomStringConvertible where Output.OutputType: Cust
74210
ExecutionResult(
75211
processIdentifier: \(self.processIdentifier),
76212
terminationStatus: \(self.terminationStatus.description),
213+
resourceUsage: \(self.resourceUsage),
77214
closureOutput: \(String(describing: self.closureOutput)),
78215
standardOutput: \(self.standardOutput.description)
79216
standardError: \(self.standardError.description)
@@ -90,6 +227,7 @@ where Output.OutputType: CustomDebugStringConvertible, Error.OutputType: CustomD
90227
ExecutionResult(
91228
processIdentifier: \(self.processIdentifier),
92229
terminationStatus: \(self.terminationStatus.debugDescription),
230+
resourceUsage: \(self.resourceUsage),
93231
closureOutput: \(String(describing: self.closureOutput)),
94232
standardOutput: \(self.standardOutput.debugDescription)
95233
standardError: \(self.standardError.debugDescription)
@@ -105,11 +243,14 @@ where Output.OutputType: CustomDebugStringConvertible, Error.OutputType: CustomD
105243
internal struct ExecutionOutcome<Result: Sendable>: Sendable {
106244
/// The termination status of the child process.
107245
internal let terminationStatus: TerminationStatus
246+
/// The resource usage of the terminated child process.
247+
internal let resourceUsage: ResourceUsage
108248
/// The value returned by the closure passed to the `run` method.
109249
internal let value: Result
110250

111-
internal init(terminationStatus: TerminationStatus, value: Result) {
251+
internal init(terminationStatus: TerminationStatus, resourceUsage: ResourceUsage, value: Result) {
112252
self.terminationStatus = terminationStatus
253+
self.resourceUsage = resourceUsage
113254
self.value = value
114255
}
115256
}
@@ -124,6 +265,7 @@ extension ExecutionOutcome: CustomStringConvertible where Result: CustomStringCo
124265
return """
125266
ExecutionOutcome(
126267
terminationStatus: \(self.terminationStatus.description),
268+
resourceUsage: \(self.resourceUsage),
127269
value: \(self.value.description)
128270
)
129271
"""
@@ -136,6 +278,7 @@ extension ExecutionOutcome: CustomDebugStringConvertible where Result: CustomDeb
136278
return """
137279
ExecutionOutcome(
138280
terminationStatus: \(self.terminationStatus.debugDescription),
281+
resourceUsage: \(self.resourceUsage),
139282
value: \(self.value.debugDescription)
140283
)
141284
"""

0 commit comments

Comments
 (0)