Skip to content

Commit 231543e

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 7a7c986 commit 231543e

10 files changed

Lines changed: 764 additions & 107 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: 174 additions & 74 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,84 +120,97 @@ 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-
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+
result = .failure(error)
178+
}
179+
180+
// Wait for the monitor child task to finish.
181+
let monitorError = try await group.next() ?? nil
182+
return (result, monitorError)
143183
}
144184

145-
// Wait for the monitor child task to finish.
146-
let monitorError = try await group.next() ?? nil
147-
return (result, monitorError)
148-
}
185+
// Drop the cancellation marker before reaping the zombie. After
186+
// `reapProcess` runs the kernel can immediately reuse this PID,
187+
// so the marker must be gone first; otherwise a concurrent
188+
// `run()` that happens to inherit the same PID would see the
189+
// stale entry and reject its registrations.
190+
AsyncIO.shared.cleanup(processIdentifier: processIdentifier)
149191

150-
// Drop the cancellation marker before reaping the zombie. After
151-
// `reapProcess` runs the kernel can immediately reuse this PID,
152-
// so the marker must be gone first; otherwise a concurrent
153-
// `run()` that happens to inherit the same PID would see the
154-
// stale entry and reject its registrations.
155-
AsyncIO.shared.cleanup(processIdentifier: processIdentifier)
192+
let terminationStatus = try reapProcess(with: processIdentifier)
156193

157-
let terminationStatus = try reapProcess(with: processIdentifier)
194+
if let monitorError {
195+
throw monitorError
196+
}
158197

159-
if let monitorError {
160-
throw monitorError
198+
return try ExecutionOutcome(
199+
terminationStatus: terminationStatus,
200+
value: result.get()
201+
)
202+
} onCleanup: {
203+
let execution = Execution<Input, Output, Error>(
204+
processIdentifier: processIdentifier,
205+
inputWriter: nil,
206+
outputStream: nil,
207+
errorStream: nil
208+
)
209+
// Attempt to terminate the child process.
210+
await execution.teardown(using: self.platformOptions.teardownSequence)
161211
}
162-
163-
return try ExecutionOutcome(
164-
terminationStatus: terminationStatus,
165-
value: result.get()
166-
)
167-
} onCleanup: {
168-
let execution = Execution<Input, Output, Error>(
169-
processIdentifier: processIdentifier,
170-
inputWriter: nil,
171-
outputStream: nil,
172-
errorStream: nil
173-
)
174-
// Attempt to terminate the child process.
175-
await execution.teardown(using: self.platformOptions.teardownSequence)
212+
} catch let error as SubprocessError {
213+
throw error.withExecutionContext(executionContext)
176214
}
177215
}
178216
}
@@ -565,6 +603,40 @@ extension Environment: CustomStringConvertible, CustomDebugStringConvertible {
565603
return results
566604
}
567605
}
606+
607+
/// The concrete environment variables passed to the child process, or
608+
/// `nil` if they cannot be represented as a `[Key: String]` map.
609+
///
610+
/// This mirrors the environment built by the platform spawn path: for
611+
/// `.inherit`, the parent's current values with the configured overrides
612+
/// applied (a `nil` override removes the key); for `.custom`, those values
613+
/// directly; for raw-byte environments, `nil`. On Windows, `PATH` is
614+
/// injected from the parent when absent, matching the spawn path's
615+
/// DLL-resolution behavior.
616+
internal func resolvedValues() -> [Environment.Key: String]? {
617+
var values: [Environment.Key: String]
618+
switch self.config {
619+
case .custom(let customValues):
620+
values = customValues
621+
case .inherit(let overrides):
622+
values = Self.currentEnvironmentValues()
623+
for (key, override) in overrides {
624+
values[key] = override
625+
}
626+
#if !os(Windows)
627+
case .rawBytes:
628+
return nil
629+
#endif
630+
}
631+
#if os(Windows)
632+
if values[.path] == nil,
633+
let parentPath = Self.currentEnvironmentValues()[.path]
634+
{
635+
values[.path] = parentPath
636+
}
637+
#endif
638+
return values
639+
}
568640
}
569641

570642
extension Environment.Key {
@@ -689,6 +761,7 @@ extension Configuration {
689761
/// via `SpawnResult` to perform actual reads
690762
internal struct SpawnResult: ~Copyable {
691763
let processIdentifier: ProcessIdentifier
764+
let resolvedExecutable: FilePath?
692765
var _inputWriteEnd: IODescriptor?
693766
var _outputReadEnd: IODescriptor?
694767
var _errorReadEnd: IODescriptor?
@@ -697,12 +770,14 @@ extension Configuration {
697770
processIdentifier: ProcessIdentifier,
698771
inputWriteEnd: consuming IODescriptor?,
699772
outputReadEnd: consuming IODescriptor?,
700-
errorReadEnd: consuming IODescriptor?
773+
errorReadEnd: consuming IODescriptor?,
774+
resolvedExecutable: FilePath? = nil
701775
) {
702776
self.processIdentifier = processIdentifier
703777
self._inputWriteEnd = consume inputWriteEnd
704778
self._outputReadEnd = consume outputReadEnd
705779
self._errorReadEnd = consume errorReadEnd
780+
self.resolvedExecutable = resolvedExecutable
706781
}
707782

708783
mutating func inputWriteEnd() -> IODescriptor? {
@@ -1338,3 +1413,28 @@ extension StringProtocol {
13381413
}
13391414
}
13401415
}
1416+
1417+
/// Best-effort lookup of the parent process's current working directory.
1418+
///
1419+
/// Returns `nil` on failure; never throws, since it only feeds diagnostics.
1420+
internal func currentWorkingDirectory() -> FilePath? {
1421+
#if os(Windows)
1422+
let length = GetCurrentDirectoryW(0, nil)
1423+
guard length > 0 else { return nil }
1424+
let path = try? fillNullTerminatedWideStringBuffer(
1425+
initialSize: length,
1426+
maxSize: DWORD(Int16.max)
1427+
) {
1428+
GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress)
1429+
}
1430+
guard let path else { return nil }
1431+
return FilePath(path)
1432+
#else
1433+
return withUnsafeTemporaryAllocation(of: CChar.self, capacity: Int(PATH_MAX)) { buffer -> FilePath? in
1434+
guard getcwd(buffer.baseAddress, buffer.count) != nil else {
1435+
return nil
1436+
}
1437+
return FilePath(platformString: buffer.baseAddress!)
1438+
}
1439+
#endif
1440+
}

0 commit comments

Comments
 (0)