Skip to content

Commit e09077a

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 1163367 commit e09077a

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
@@ -98,11 +98,36 @@ public struct Configuration: Sendable {
9898
) async throws -> Result
9999
)
100100
) async throws -> ExecutionOutcome<Result> {
101-
var spawnResults = try await self.spawn(
102-
withInput: input,
103-
outputPipe: output,
104-
errorPipe: error
105-
)
101+
// Built before spawning so it can be attached to spawn failures, where
102+
// no resolved values exist yet.
103+
let baseContext = ExecutionContext(self)
104+
105+
var spawnResults: SpawnResult
106+
#if os(Windows)
107+
// `spawn` is `throws(SubprocessError)` on Windows, so `error` is
108+
// already typed; an `as SubprocessError` pattern is a redundant cast
109+
// that crashes the 6.2 toolchain's SIL ownership verifier. Elsewhere
110+
// `spawn` is untyped `throws`, where the cast is meaningful.
111+
do {
112+
spawnResults = try await self.spawn(
113+
withInput: input,
114+
outputPipe: output,
115+
errorPipe: error
116+
)
117+
} catch {
118+
throw error.withExecutionContext(baseContext)
119+
}
120+
#else
121+
do {
122+
spawnResults = try await self.spawn(
123+
withInput: input,
124+
outputPipe: output,
125+
errorPipe: error
126+
)
127+
} catch let error as SubprocessError {
128+
throw error.withExecutionContext(baseContext)
129+
}
130+
#endif
106131

107132
let processIdentifier = spawnResults.processIdentifier
108133

@@ -111,84 +136,97 @@ public struct Configuration: Sendable {
111136
processIdentifier.close()
112137
}
113138

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

147-
let inputIO = spawnResults.inputWriteEnd()
148-
let outputIO = spawnResults.outputReadEnd()
149-
let errorIO = spawnResults.errorReadEnd()
182+
let inputIO = spawnResults.inputWriteEnd()
183+
let outputIO = spawnResults.outputReadEnd()
184+
let errorIO = spawnResults.errorReadEnd()
150185

151-
let result: Swift.Result<Result, any Swift.Error>
152-
do {
153-
// Body runs in the same isolation.
154-
let bodyResult = try await body(processIdentifier, inputIO, outputIO, errorIO)
155-
taskFinishFlag.addOne()
156-
result = .success(bodyResult)
157-
} catch {
158-
result = .failure(error)
186+
let result: Swift.Result<Result, any Swift.Error>
187+
do {
188+
// Body runs in the same isolation.
189+
let bodyResult = try await body(processIdentifier, inputIO, outputIO, errorIO)
190+
taskFinishFlag.addOne()
191+
result = .success(bodyResult)
192+
} catch {
193+
result = .failure(error)
194+
}
195+
196+
// Wait for the monitor child task to finish.
197+
let monitorError = try await group.next() ?? nil
198+
return (result, monitorError)
159199
}
160200

161-
// Wait for the monitor child task to finish.
162-
let monitorError = try await group.next() ?? nil
163-
return (result, monitorError)
164-
}
201+
// Drop the cancellation marker before reaping the zombie. After
202+
// `reapProcess` runs the kernel can immediately reuse this PID,
203+
// so the marker must be gone first; otherwise a concurrent
204+
// `run()` that happens to inherit the same PID would see the
205+
// stale entry and reject its registrations.
206+
AsyncIO.shared.cleanup(processIdentifier: processIdentifier)
165207

166-
// Drop the cancellation marker before reaping the zombie. After
167-
// `reapProcess` runs the kernel can immediately reuse this PID,
168-
// so the marker must be gone first; otherwise a concurrent
169-
// `run()` that happens to inherit the same PID would see the
170-
// stale entry and reject its registrations.
171-
AsyncIO.shared.cleanup(processIdentifier: processIdentifier)
208+
let terminationStatus = try reapProcess(with: processIdentifier)
172209

173-
let terminationStatus = try reapProcess(with: processIdentifier)
210+
if let monitorError {
211+
throw monitorError
212+
}
174213

175-
if let monitorError {
176-
throw monitorError
214+
return try ExecutionOutcome(
215+
terminationStatus: terminationStatus,
216+
value: result.get()
217+
)
218+
} onCleanup: {
219+
let execution = Execution<Input, Output, Error>(
220+
processIdentifier: processIdentifier,
221+
inputWriter: nil,
222+
outputStream: nil,
223+
errorStream: nil
224+
)
225+
// Attempt to terminate the child process.
226+
await execution.teardown(using: self.platformOptions.teardownSequence)
177227
}
178-
179-
return try ExecutionOutcome(
180-
terminationStatus: terminationStatus,
181-
value: result.get()
182-
)
183-
} onCleanup: {
184-
let execution = Execution<Input, Output, Error>(
185-
processIdentifier: processIdentifier,
186-
inputWriter: nil,
187-
outputStream: nil,
188-
errorStream: nil
189-
)
190-
// Attempt to terminate the child process.
191-
await execution.teardown(using: self.platformOptions.teardownSequence)
228+
} catch let error as SubprocessError {
229+
throw error.withExecutionContext(executionContext)
192230
}
193231
}
194232
}
@@ -581,6 +619,40 @@ extension Environment: CustomStringConvertible, CustomDebugStringConvertible {
581619
return results
582620
}
583621
}
622+
623+
/// The concrete environment variables passed to the child process, or
624+
/// `nil` if they cannot be represented as a `[Key: String]` map.
625+
///
626+
/// This mirrors the environment built by the platform spawn path: for
627+
/// `.inherit`, the parent's current values with the configured overrides
628+
/// applied (a `nil` override removes the key); for `.custom`, those values
629+
/// directly; for raw-byte environments, `nil`. On Windows, `PATH` is
630+
/// injected from the parent when absent, matching the spawn path's
631+
/// DLL-resolution behavior.
632+
internal func resolvedValues() -> [Environment.Key: String]? {
633+
var values: [Environment.Key: String]
634+
switch self.config {
635+
case .custom(let customValues):
636+
values = customValues
637+
case .inherit(let overrides):
638+
values = Self.currentEnvironmentValues()
639+
for (key, override) in overrides {
640+
values[key] = override
641+
}
642+
#if !os(Windows)
643+
case .rawBytes:
644+
return nil
645+
#endif
646+
}
647+
#if os(Windows)
648+
if values[.path] == nil,
649+
let parentPath = Self.currentEnvironmentValues()[.path]
650+
{
651+
values[.path] = parentPath
652+
}
653+
#endif
654+
return values
655+
}
584656
}
585657

586658
extension Environment.Key {
@@ -705,6 +777,7 @@ extension Configuration {
705777
/// via `SpawnResult` to perform actual reads
706778
internal struct SpawnResult: ~Copyable {
707779
let processIdentifier: ProcessIdentifier
780+
let resolvedExecutable: FilePath?
708781
var _inputWriteEnd: IODescriptor?
709782
var _outputReadEnd: IODescriptor?
710783
var _errorReadEnd: IODescriptor?
@@ -713,12 +786,14 @@ extension Configuration {
713786
processIdentifier: ProcessIdentifier,
714787
inputWriteEnd: consuming IODescriptor?,
715788
outputReadEnd: consuming IODescriptor?,
716-
errorReadEnd: consuming IODescriptor?
789+
errorReadEnd: consuming IODescriptor?,
790+
resolvedExecutable: FilePath? = nil
717791
) {
718792
self.processIdentifier = processIdentifier
719793
self._inputWriteEnd = consume inputWriteEnd
720794
self._outputReadEnd = consume outputReadEnd
721795
self._errorReadEnd = consume errorReadEnd
796+
self.resolvedExecutable = resolvedExecutable
722797
}
723798

724799
mutating func inputWriteEnd() -> IODescriptor? {
@@ -1354,3 +1429,28 @@ extension StringProtocol {
13541429
}
13551430
}
13561431
}
1432+
1433+
/// Best-effort lookup of the parent process's current working directory.
1434+
///
1435+
/// Returns `nil` on failure; never throws, since it only feeds diagnostics.
1436+
internal func currentWorkingDirectory() -> FilePath? {
1437+
#if os(Windows)
1438+
let length = GetCurrentDirectoryW(0, nil)
1439+
guard length > 0 else { return nil }
1440+
let path = try? fillNullTerminatedWideStringBuffer(
1441+
initialSize: length,
1442+
maxSize: DWORD(Int16.max)
1443+
) {
1444+
GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress)
1445+
}
1446+
guard let path else { return nil }
1447+
return FilePath(path)
1448+
#else
1449+
return withUnsafeTemporaryAllocation(of: CChar.self, capacity: Int(PATH_MAX)) { buffer -> FilePath? in
1450+
guard getcwd(buffer.baseAddress, buffer.count) != nil else {
1451+
return nil
1452+
}
1453+
return FilePath(platformString: buffer.baseAddress!)
1454+
}
1455+
#endif
1456+
}

0 commit comments

Comments
 (0)