Skip to content

Commit a001b92

Browse files
committed
Add ExecutionContext to snapshot command details
`SubprocessError` currently surfaces what went wrong via `code` and `underlyingError`, but not what was being run. Add `SubprocessError.executionContext`, representing a snapshot of the executable, arguments, environment, and working directory configured at the time the error was thrown, along with values resolved at spawn time.
1 parent 6b0d119 commit a001b92

10 files changed

Lines changed: 772 additions & 115 deletions

Sources/Subprocess/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
add_library(Subprocess
1313
Execution.swift
14+
ExecutionContext.swift
1415
Buffer.swift
1516
Error.swift
1617
Teardown.swift

Sources/Subprocess/Configuration.swift

Lines changed: 182 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,36 @@ public struct Configuration: Sendable {
8282
) async throws -> Result
8383
)
8484
) async throws -> ExecutionOutcome<Result> {
85-
var spawnResults = try await self.spawn(
86-
withInput: input,
87-
outputPipe: output,
88-
errorPipe: error
89-
)
85+
// Built before spawning so it can be attached to spawn failures, where
86+
// no resolved values exist yet.
87+
let baseContext = ExecutionContext(self)
88+
89+
var spawnResults: SpawnResult
90+
#if os(Windows)
91+
// `spawn` is `throws(SubprocessError)` on Windows, so `error` is
92+
// already typed; an `as SubprocessError` pattern is a redundant cast
93+
// that crashes the 6.2 toolchain's SIL ownership verifier. Elsewhere
94+
// `spawn` is untyped `throws`, where the cast is meaningful.
95+
do {
96+
spawnResults = try await self.spawn(
97+
withInput: input,
98+
outputPipe: output,
99+
errorPipe: error
100+
)
101+
} catch {
102+
throw error.withExecutionContext(baseContext)
103+
}
104+
#else
105+
do {
106+
spawnResults = try await self.spawn(
107+
withInput: input,
108+
outputPipe: output,
109+
errorPipe: error
110+
)
111+
} catch let error as SubprocessError {
112+
throw error.withExecutionContext(baseContext)
113+
}
114+
#endif
90115

91116
let processIdentifier = spawnResults.processIdentifier
92117

@@ -95,92 +120,105 @@ public struct Configuration: Sendable {
95120
processIdentifier.close()
96121
}
97122

98-
return try await withAsyncTaskCleanupHandler { () throws -> ExecutionOutcome<Result> in
99-
// The counter coordinates a race between two finishers: the body
100-
// closure and the process-termination monitor. Whichever side
101-
// increments first observes a value of `1` and owns the response;
102-
// the other side sees `2` and stays out of the way.
103-
//
104-
// If the monitor wins, the child has exited while the body is
105-
// still reading or writing. The body might be blocked on a pipe
106-
// that an inherited grandchild keeps open, so the monitor calls
107-
// `cancelAsyncIO` to unblock it. If the body wins, the I/O
108-
// already finished cleanly and no cancellation is needed.
109-
let taskFinishFlag = AtomicCounter()
110-
111-
let (result, monitorError) = try await withThrowingTaskGroup(
112-
of: SubprocessError?.self,
113-
returning: (Swift.Result<Result, any Swift.Error>, SubprocessError?).self
114-
) { group in
115-
group.addTask {
116-
do throws(SubprocessError) {
117-
try await waitForProcessTermination(for: processIdentifier)
118-
if taskFinishFlag.addOne() == 1 {
119-
// The body closure hasn't finished but the child
120-
// process has terminated. Cancel all active
121-
// AsyncIO now.
122-
try AsyncIO.shared.cancelAsyncIO(for: processIdentifier)
123+
// Spawn succeeded, so resolved values are available. This context is
124+
// attached to any error surfacing after spawn.
125+
let executionContext = ExecutionContext(
126+
self,
127+
resolvedExecutable: spawnResults.resolvedExecutable,
128+
resolvedEnvironment: self.environment.resolvedValues(),
129+
resolvedWorkingDirectory: self.workingDirectory ?? currentWorkingDirectory()
130+
)
131+
132+
do {
133+
return try await withAsyncTaskCleanupHandler { () throws -> ExecutionOutcome<Result> in
134+
// The counter coordinates a race between two finishers: the body
135+
// closure and the process-termination monitor. Whichever side
136+
// increments first observes a value of `1` and owns the response;
137+
// the other side sees `2` and stays out of the way.
138+
//
139+
// If the monitor wins, the child has exited while the body is
140+
// still reading or writing. The body might be blocked on a pipe
141+
// that an inherited grandchild keeps open, so the monitor calls
142+
// `cancelAsyncIO` to unblock it. If the body wins, the I/O
143+
// already finished cleanly and no cancellation is needed.
144+
let taskFinishFlag = AtomicCounter()
145+
146+
let (result, monitorError) = try await withThrowingTaskGroup(
147+
of: SubprocessError?.self,
148+
returning: (Swift.Result<Result, any Swift.Error>, SubprocessError?).self
149+
) { group in
150+
group.addTask {
151+
do throws(SubprocessError) {
152+
try await waitForProcessTermination(for: processIdentifier)
153+
if taskFinishFlag.addOne() == 1 {
154+
// The body closure hasn't finished but the child
155+
// process has terminated. Cancel all active
156+
// AsyncIO now.
157+
try AsyncIO.shared.cancelAsyncIO(for: processIdentifier)
158+
}
159+
return nil
160+
} catch {
161+
try? AsyncIO.shared.cancelAsyncIO(for: processIdentifier)
162+
return error
123163
}
124-
return nil
125-
} catch {
126-
try? AsyncIO.shared.cancelAsyncIO(for: processIdentifier)
127-
return error
128164
}
129-
}
130165

131-
let inputIO = spawnResults.inputWriteEnd()
132-
let outputIO = spawnResults.outputReadEnd()
133-
let errorIO = spawnResults.errorReadEnd()
166+
let inputIO = spawnResults.inputWriteEnd()
167+
let outputIO = spawnResults.outputReadEnd()
168+
let errorIO = spawnResults.errorReadEnd()
134169

135-
let result: Swift.Result<Result, any Swift.Error>
136-
do {
137-
// Body runs in the same isolation.
138-
let bodyResult = try await body(processIdentifier, inputIO, outputIO, errorIO)
139-
taskFinishFlag.addOne()
140-
result = .success(bodyResult)
141-
} catch {
142-
let execution = Execution<Input, Output, Error>(
143-
processIdentifier: processIdentifier,
144-
inputWriter: nil,
145-
outputStream: nil,
146-
errorStream: nil
147-
)
148-
// Attempt to terminate the child process when the body throws
149-
await execution.teardown(using: self.platformOptions.teardownSequence)
150-
result = .failure(error)
170+
let result: Swift.Result<Result, any Swift.Error>
171+
do {
172+
// Body runs in the same isolation.
173+
let bodyResult = try await body(processIdentifier, inputIO, outputIO, errorIO)
174+
taskFinishFlag.addOne()
175+
result = .success(bodyResult)
176+
} catch {
177+
let execution = Execution<Input, Output, Error>(
178+
processIdentifier: processIdentifier,
179+
inputWriter: nil,
180+
outputStream: nil,
181+
errorStream: nil
182+
)
183+
// Attempt to terminate the child process when the body throws
184+
await execution.teardown(using: self.platformOptions.teardownSequence)
185+
result = .failure(error)
186+
}
187+
188+
// Wait for the monitor child task to finish.
189+
let monitorError = try await group.next() ?? nil
190+
return (result, monitorError)
151191
}
152192

153-
// Wait for the monitor child task to finish.
154-
let monitorError = try await group.next() ?? nil
155-
return (result, monitorError)
156-
}
193+
// Drop the cancellation marker before reaping the zombie. After
194+
// `reapProcess` runs the kernel can immediately reuse this PID,
195+
// so the marker must be gone first; otherwise a concurrent
196+
// `run()` that happens to inherit the same PID would see the
197+
// stale entry and reject its registrations.
198+
AsyncIO.shared.cleanup(processIdentifier: processIdentifier)
157199

158-
// Drop the cancellation marker before reaping the zombie. After
159-
// `reapProcess` runs the kernel can immediately reuse this PID,
160-
// so the marker must be gone first; otherwise a concurrent
161-
// `run()` that happens to inherit the same PID would see the
162-
// stale entry and reject its registrations.
163-
AsyncIO.shared.cleanup(processIdentifier: processIdentifier)
200+
let terminationStatus = try reapProcess(with: processIdentifier)
164201

165-
let terminationStatus = try reapProcess(with: processIdentifier)
202+
if let monitorError {
203+
throw monitorError
204+
}
166205

167-
if let monitorError {
168-
throw monitorError
206+
return try ExecutionOutcome(
207+
terminationStatus: terminationStatus,
208+
value: result.get()
209+
)
210+
} onCleanup: {
211+
let execution = Execution<Input, Output, Error>(
212+
processIdentifier: processIdentifier,
213+
inputWriter: nil,
214+
outputStream: nil,
215+
errorStream: nil
216+
)
217+
// Attempt to terminate the child process.
218+
await execution.teardown(using: self.platformOptions.teardownSequence)
169219
}
170-
171-
return try ExecutionOutcome(
172-
terminationStatus: terminationStatus,
173-
value: result.get()
174-
)
175-
} onCleanup: {
176-
let execution = Execution<Input, Output, Error>(
177-
processIdentifier: processIdentifier,
178-
inputWriter: nil,
179-
outputStream: nil,
180-
errorStream: nil
181-
)
182-
// Attempt to terminate the child process.
183-
await execution.teardown(using: self.platformOptions.teardownSequence)
220+
} catch let error as SubprocessError {
221+
throw error.withExecutionContext(executionContext)
184222
}
185223
}
186224
}
@@ -573,6 +611,40 @@ extension Environment: CustomStringConvertible, CustomDebugStringConvertible {
573611
return results
574612
}
575613
}
614+
615+
/// The concrete environment variables passed to the child process, or
616+
/// `nil` if they cannot be represented as a `[Key: String]` map.
617+
///
618+
/// This mirrors the environment built by the platform spawn path: for
619+
/// `.inherit`, the parent's current values with the configured overrides
620+
/// applied (a `nil` override removes the key); for `.custom`, those values
621+
/// directly; for raw-byte environments, `nil`. On Windows, `PATH` is
622+
/// injected from the parent when absent, matching the spawn path's
623+
/// DLL-resolution behavior.
624+
internal func resolvedValues() -> [Environment.Key: String]? {
625+
var values: [Environment.Key: String]
626+
switch self.config {
627+
case .custom(let customValues):
628+
values = customValues
629+
case .inherit(let overrides):
630+
values = Self.currentEnvironmentValues()
631+
for (key, override) in overrides {
632+
values[key] = override
633+
}
634+
#if !os(Windows)
635+
case .rawBytes:
636+
return nil
637+
#endif
638+
}
639+
#if os(Windows)
640+
if values[.path] == nil,
641+
let parentPath = Self.currentEnvironmentValues()[.path]
642+
{
643+
values[.path] = parentPath
644+
}
645+
#endif
646+
return values
647+
}
576648
}
577649

578650
extension Environment.Key {
@@ -697,6 +769,7 @@ extension Configuration {
697769
/// via `SpawnResult` to perform actual reads
698770
internal struct SpawnResult: ~Copyable {
699771
let processIdentifier: ProcessIdentifier
772+
let resolvedExecutable: FilePath?
700773
var _inputWriteEnd: IODescriptor?
701774
var _outputReadEnd: IODescriptor?
702775
var _errorReadEnd: IODescriptor?
@@ -705,12 +778,14 @@ extension Configuration {
705778
processIdentifier: ProcessIdentifier,
706779
inputWriteEnd: consuming IODescriptor?,
707780
outputReadEnd: consuming IODescriptor?,
708-
errorReadEnd: consuming IODescriptor?
781+
errorReadEnd: consuming IODescriptor?,
782+
resolvedExecutable: FilePath? = nil
709783
) {
710784
self.processIdentifier = processIdentifier
711785
self._inputWriteEnd = consume inputWriteEnd
712786
self._outputReadEnd = consume outputReadEnd
713787
self._errorReadEnd = consume errorReadEnd
788+
self.resolvedExecutable = resolvedExecutable
714789
}
715790

716791
mutating func inputWriteEnd() -> IODescriptor? {
@@ -1346,3 +1421,28 @@ extension StringProtocol {
13461421
}
13471422
}
13481423
}
1424+
1425+
/// Best-effort lookup of the parent process's current working directory.
1426+
///
1427+
/// Returns `nil` on failure; never throws, since it only feeds diagnostics.
1428+
internal func currentWorkingDirectory() -> FilePath? {
1429+
#if os(Windows)
1430+
let length = GetCurrentDirectoryW(0, nil)
1431+
guard length > 0 else { return nil }
1432+
let path = try? fillNullTerminatedWideStringBuffer(
1433+
initialSize: length,
1434+
maxSize: DWORD(Int16.max)
1435+
) {
1436+
GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress)
1437+
}
1438+
guard let path else { return nil }
1439+
return FilePath(path)
1440+
#else
1441+
return withUnsafeTemporaryAllocation(of: CChar.self, capacity: Int(PATH_MAX)) { buffer -> FilePath? in
1442+
guard getcwd(buffer.baseAddress, buffer.count) != nil else {
1443+
return nil
1444+
}
1445+
return FilePath(platformString: buffer.baseAddress!)
1446+
}
1447+
#endif
1448+
}

0 commit comments

Comments
 (0)