Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
7bffa89
Start on windows support
cobbal Jun 19, 2023
a8b2597
more work towards windows spawn
cobbal Jun 30, 2023
823d084
windows now compiles, tests don't compile, nothing works
cobbal Jul 29, 2023
c8e578b
windows partial hookup to ProcessSpawner protocol
cobbal Oct 7, 2023
8e8fe46
windows stuff
cobbal Oct 28, 2023
11bc4e5
Got code and test suite running. Super-dumb processes reaping returns…
mstokercricut Dec 12, 2023
95db2f7
Added ProcessInformation struct to keep handles around on Windows pla…
mstokercricut Dec 13, 2023
db011ee
Adjusted output of command lacking an terminating line ending to retu…
mstokercricut Dec 13, 2023
a94cee1
Started correcting stdin/stdout
mstokercricut Dec 13, 2023
0d8bffa
Added wait in async reap for the process to complete. Added context t…
mstokercricut Dec 14, 2023
1c07607
Tried more things to get stdout to show up. Added an executable targe…
mstokercricut Dec 14, 2023
9d42f59
Tried Application Verifier. Complained about a critical section viola…
mstokercricut Dec 14, 2023
e15096a
Got stdin/stdout working\! Moved WindowSpawnImpl to main swsh target
mstokercricut Dec 14, 2023
2421c16
Moved WindowsSpawnImpl into swsh proper. Added string processing meth…
mstokercricut Dec 15, 2023
cc42896
Fixed memory issues with path searching
mstokercricut Dec 15, 2023
c7c277d
Added FDFileHandle to make referring to an fd and a FileHandle at the…
mstokercricut Dec 18, 2023
34adb6a
Got pipes working\! runString() too
mstokercricut Dec 18, 2023
682b4c2
Moved file handle flagging to ChildHandleStructure
mstokercricut Dec 18, 2023
f3f5baf
Closing happens correctly now. Probably.
mstokercricut Dec 19, 2023
64e0663
Fixed tests
mstokercricut Dec 19, 2023
89cc4eb
Debugging input pipes
mstokercricut Dec 19, 2023
a237e3a
Went over handle logic, made it prettier
mstokercricut Dec 20, 2023
1ecf7cf
Switched from _pipe to win32 calls. Added printOSCall() debugging
mstokercricut Dec 23, 2023
719aae0
DispatchIO.write() simply doesn't work well on Windows. Switched to s…
mstokercricut Dec 23, 2023
cd0f26c
Disabled debug printing
mstokercricut Dec 23, 2023
9867681
Added supplementary path variable to allow adding to the path without…
mstokercricut Dec 23, 2023
31134d9
Got macOS building again
mstokercricut Dec 23, 2023
033008e
Fixed switched params to kill()
mstokercricut Dec 23, 2023
b76bff0
Changed return type of asyncStream() to maintain liveness of the FDFi…
mstokercricut Dec 23, 2023
ee5c22d
Fixed linux build
mstokercricut Dec 23, 2023
ecff70e
Fixed a windows build issue
mstokercricut Dec 23, 2023
36e4873
Fixed a macOS build issue
mstokercricut Dec 23, 2023
a664f1c
Added environment processing & cascading to child process
mstokercricut Dec 24, 2023
2cbe817
Restored working test
mstokercricut Dec 24, 2023
58cb9ae
Eliminated _kill() redirect
mstokercricut Dec 24, 2023
0d839eb
Added pseudo file redirect to NUL device on Windows
mstokercricut Dec 25, 2023
3a6f01e
Restored succeeding test
mstokercricut Dec 25, 2023
6c2cc3a
Investigated duplicateFd. Can't tell if it should succeed in the firs…
mstokercricut Dec 25, 2023
00bdce7
Changed from using WaitSingle to RegisterWaitSingle
mstokercricut Dec 25, 2023
2e18bff
Reversed the order of kill on pipelines so Windows kill works
mstokercricut Dec 26, 2023
e6e833e
Made pipline kill reversal windows only
mstokercricut Dec 26, 2023
14666ec
Fixed leak
mstokercricut Dec 26, 2023
34102fa
Played with remap cycle test
mstokercricut Dec 27, 2023
2b5e647
Removed print
mstokercricut Dec 27, 2023
964079f
Fixed duplicateFd test for more-strict-windows
mstokercricut Jan 3, 2024
697ca2c
Changed pipeline kill behavior and error code handling
mstokercricut Jan 3, 2024
037cc65
Expanded pipes to be read/write on both ends. Added (commented) code …
mstokercricut Jan 3, 2024
c2f00e6
Made handle inheritance controlled by a variable
mstokercricut Jan 3, 2024
964ff82
Changed remap test to call a custom executable
mstokercricut Jan 4, 2024
0eee4a8
Used less standard name for test C file. Could use tmp, I suppose...
mstokercricut Jan 4, 2024
50fab3c
Added uuid, made windows compile with .exe extension
mstokercricut Jan 4, 2024
1a6d320
Fixed test for macOS
mstokercricut Jan 5, 2024
56bc6b7
Added a sleep to allow bash to start up before killing it (which was …
mstokercricut Jan 5, 2024
854d783
Added FileManager.nullDevicePath as a way to get the null device. Ext…
mstokercricut Jan 5, 2024
a5e1cee
Eliminated several warnings
mstokercricut Jan 6, 2024
1fa73d5
bids adieu to swsher, may it rest in peace
mstokercricut Jan 6, 2024
4e1cb32
Removes the supplementary path in favor of using the Path environment…
mstokercricut Jan 6, 2024
e3674c7
Removed a missed supplementary path line
mstokercricut Jan 6, 2024
6e3d2cd
Removed unnecessary osHandle from FDFileHandle
mstokercricut Jan 6, 2024
c132fab
Small spacing changes to make PR look pretty
mstokercricut Jan 6, 2024
bb2201f
try to fix open call on windows
cobbal May 15, 2025
6683e4a
Merge pull request #1 from cobbal/cobbal/windows-fix
mstokercricut May 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ vendor
.bundle
docs
docs.tar.xz
.vscode
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ let package = Package(
name: "swsh",
dependencies: [
.target(name: "linuxSpawn", condition: .when(platforms: [.linux])),
.target(name: "windowsSpawn", condition: .when(platforms: [.windows])),
]
),
.testTarget(
Expand All @@ -28,5 +29,9 @@ let package = Package(
name: "linuxSpawn",
dependencies: []
),
.target(
name: "windowsSpawn",
dependencies: []
),
]
)
10 changes: 5 additions & 5 deletions Sources/swsh/Command+async.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ extension Command {
/// - Returns: output as Data
@available(macOS 10.15, *)
public func runData() async throws -> Data {
let pipe = Pipe()
let write = pipe.fileHandleForWriting
let result = async(fdMap: [ .stdout: write.fd ])
close(write.fileDescriptor)
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let pipe = FDPipe()
let result = async(fdMap: [ .stdout: pipe.fileHandleForWriting.fileDescriptor ])
pipe.fileHandleForWriting.close()
let data = pipe.fileHandleForReading.handle.readDataToEndOfFile()
pipe.fileHandleForReading.close()
try await result.succeed()
return data
}
Expand Down
20 changes: 10 additions & 10 deletions Sources/swsh/Command.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ extension Command {
}

/// Run the command asynchronously, and return a stream open on process's stdout
public func asyncStream() -> FileHandle {
let pipe = Pipe()
let write = pipe.fileHandleForWriting
_ = async(fdMap: [ .stdout: write.fd ])
close(write.fileDescriptor)
public func asyncStream() -> FDFileHandle {
let pipe = FDPipe()
_ = async(fdMap: [ .stdout: pipe.fileHandleForWriting.fileDescriptor ])
pipe.fileHandleForWriting.close()
// TODO: Previously, this returned a FileHandle, but the test using it fails without returning an FDFileHandle, because otherwise, on return, the FDFileHandle is deallocate and calls close(). Can maintain liveness of the FDFileHandle in some way without changing the interface of this call?
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To do that, just duplicate the handle

return pipe.fileHandleForReading
}

Expand Down Expand Up @@ -68,11 +68,10 @@ extension Command {
/// - Throws: if command fails
/// - Returns: output as Data
public func runData() throws -> Data {
let pipe = Pipe()
let write = pipe.fileHandleForWriting
let result = async(fdMap: [ .stdout: write.fd ])
close(write.fileDescriptor)
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let pipe = FDPipe()
let result = async(fdMap: [ .stdout: pipe.fileHandleForWriting.fileDescriptor ])
pipe.fileHandleForWriting.close()
let data = pipe.fileHandleForReading.handle.readDataToEndOfFile()
try result.succeed()
return data
}
Expand All @@ -89,6 +88,7 @@ extension Command {
throw InvalidString(data: data, encoding: encoding)
}
guard let trimStop = string.lastIndex(where: { $0 != "\n" }) else {
// TODO: Shoudn't this return `string`?
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the string is all newlines and should be completely trimmed.

return ""
}
return String(string[...trimStop])
Expand Down
18 changes: 17 additions & 1 deletion Sources/swsh/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,19 @@ public class SyscallError: Error, CommandResult, CustomStringConvertible {
}

public var description: String {
"\(name) failed with error code \(errno): \(String(cString: strerror(errno)))"
let errorMessage: String
#if os(Windows)
let errlen = 1024 // Is this enough? Windows is badly designed and poorly documented
errorMessage = withUnsafeTemporaryAllocation(of: CChar.self, capacity: errlen + 1) { buffer in
strerror_s(buffer.baseAddress, errlen, errno)
// Ensure we have at least 1 null terminator, not sure if this is needed
buffer[errlen] = 0
return String(cString: buffer.baseAddress!)
}
#else
errorMessage = String(cString: strerror(errno))
#endif
return "\(name) failed with error code \(errno): \(errorMessage)"
}

public func succeed() throws {
Expand All @@ -59,3 +71,7 @@ public class SyscallError: Error, CommandResult, CustomStringConvertible {
throw self
}
}

enum PlatformError: Error {
case killUnsupportedOnWindows
}
53 changes: 42 additions & 11 deletions Sources/swsh/ExternalCommand.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if os(Windows)
import WinSDK
#endif

/// Represents an external program invocation. It is the lowest-level command, that will spawn a subprocess when run.
public class ExternalCommand: Command, CustomStringConvertible {
Expand All @@ -9,7 +12,7 @@ public class ExternalCommand: Command, CustomStringConvertible {

public let description: String

/// like "set -x", this will cause all external commands to print themselves when they run
/// Like "set -x", this will cause all external commands to print themselves when they run
public static var verbose: Bool = false

internal var spawner: ProcessSpawner
Expand Down Expand Up @@ -50,6 +53,8 @@ public class ExternalCommand: Command, CustomStringConvertible {
self.spawner = PosixSpawn()
#elseif canImport(Glibc)
self.spawner = LinuxSpawn()
#elseif canImport(ucrt)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mismatched with the import statement at the top of the file. They should both have the same #condition.

self.spawner = WindowsSpawn()
#endif
}

Expand All @@ -58,34 +63,60 @@ public class ExternalCommand: Command, CustomStringConvertible {

let name: String
var command: Command
let pid: pid_t
let process: ProcessInformation

private var _exitCode: Int32?
private var _exitSemaphore = DispatchSemaphore(value: 0)
private var _exitContinuations: [() -> Void] = []

init(command: ExternalCommand, pid: pid_t) {
init(command: ExternalCommand, process: ProcessInformation) {
self.command = command
self.name = command.command
self.pid = pid
self.process = process

command.spawner.reapAsync(pid: pid, queue: Result.reaperQueue) { [weak self] exitCode in
command.spawner.reapAsync(process: process, queue: Result.reaperQueue) { [weak self] exitCode in
self?._exitCode = exitCode
self?._exitSemaphore.signal()
self?._exitContinuations.forEach { $0() }
self?._exitContinuations = []
}

try? kill(signal: SIGCONT)
try? command.spawner.resume(process: process)
}

var isRunning: Bool {
Result.reaperQueue.sync { _exitCode == nil }
}

static let NtSuspendProcess: (ProcessInformation) -> Int32 = {
// TODO: Tie into (undocumented) NtSuspendProcess() syscall
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be a noop

return { _ in 0 }
}()

static let NtResumeProcess: (ProcessInformation) -> Int32 = {
// TODO: Tie into (undocumented) NtResumeProcess() syscall
return { _ in 0 }
}()

func kill(signal: Int32) throws {
guard Foundation.kill(pid, signal) == 0 else {
#if os(Windows)
// TODO: figure out how to do this in-executable?
if signal == SIGTERM || signal == SIGKILL {
try cmd("taskkill.exe", "/F", "/pid", "\(process.id)").run()
} else if signal == SIGSTOP {
// Method borrowed from https://github.com/giampaolo/psutil/blob/a7e70bb66d5823f2cdcdf0f950bdbf26875058b4/psutil/arch/windows/proc.c#L539
// See also https://ntopcode.wordpress.com/2018/01/16/anatomy-of-the-thread-suspension-mechanism-in-windows-windows-internals/
_ = Self.NtSuspendProcess(process)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These errors shouldn't be ignored

} else if signal == SIGCONT {
_ = Self.NtResumeProcess(process)
} else {
throw PlatformError.killUnsupportedOnWindows
}
#else
guard Foundation.kill(process.id, signal) == 0 else {
throw SyscallError(name: "kill", command: command, errno: errno)
}
#endif
}

func succeed() throws { try defaultSucceed(name: name) }
Expand Down Expand Up @@ -125,22 +156,22 @@ public class ExternalCommand: Command, CustomStringConvertible {
fdMap: fdMap,
pathResolve: true
) {
case .success(let pid):
return Result(command: self, pid: pid)
case .success(let process):
return Result(command: self, process: process)
case .error(let err):
return SyscallError(name: "launching \"\(command)\"", command: self, errno: err)
}
}
}

/// Convenience function for creating an extternal command. Does **not** run the command.
/// Convenience function for creating an external command. Does **not** run the command.
/// - Parameter command: The executable to run
/// - Parameter arguments: The command line arguments to pass. No substitution is performed
public func cmd(_ command: String, arguments: [String], addEnv: [String: String] = [:]) -> Command {
ExternalCommand(command, arguments: arguments, addEnv: addEnv)
}

/// Convenience function for creating an extternal command. Does **not** run the command.
/// Convenience function for creating an external command. Does **not** run the command.
/// - Parameter command: The executable to run
/// - Parameter arguments: The command line arguments to pass. No substitution is performed
public func cmd(_ command: String, _ arguments: String..., addEnv: [String: String] = [:]) -> Command {
Expand Down
50 changes: 50 additions & 0 deletions Sources/swsh/FDFileHandle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

/// A version of `FileHandle` that can be accessed by handle or fd at the same time
public class FDFileHandle: CustomDebugStringConvertible {
public let fileDescriptor: FileDescriptor
public let handle: FileHandle
private let closeOnDealloc: Bool
private var isClosed: Bool

public convenience init(fileDescriptor: FileDescriptor, closeOnDealloc: Bool) {
// Construct the handle around the fd, but do not use closeOnDealloc, as this closes the fd!
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Construct the handle around the fd, but do not use closeOnDealloc, as this closes the fd!
// Construct the handle around the fd, but do not use closeOnDealloc, as this closes the fd immediately on windows!

let handle = FileHandle(fileDescriptor: fileDescriptor.rawValue, closeOnDealloc: false)
self.init(fileDescriptor: fileDescriptor, handle: handle, closeOnDealloc: closeOnDealloc)
}

public init(fileDescriptor: FileDescriptor, handle: FileHandle, closeOnDealloc: Bool) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a strange interface to provide publicly. Is this needed?

self.fileDescriptor = fileDescriptor
self.handle = handle
self.closeOnDealloc = closeOnDealloc
self.isClosed = false
// print("Created FDFileHandle for \(debugDescription)")
}

public func close() {
precondition(!isClosed)
close_fd(fileDescriptor)
isClosed = true
// print("Closed FDFileHandle for \(debugDescription)")
}

public var debugDescription: String {
return "fd: \(fileDescriptor.rawValue) handle: \(handle) closeOnDealloc: \(closeOnDealloc)"
}

deinit {
if closeOnDealloc && !isClosed {
close()
}
}
}

fileprivate func close_fd(_ fileDescriptor: FileDescriptor) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be named "closeFD" or better yet, just inlined into "close"

#if os(Windows)
printOSCall("_close", fileDescriptor.rawValue)
_close(fileDescriptor.rawValue)
#else
printOSCall("close", fileDescriptor.rawValue)
close(fileDescriptor.rawValue)
#endif
}
91 changes: 91 additions & 0 deletions Sources/swsh/FDPipe.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Foundation
#if os(Windows)
import WinSDK
import ucrt
#endif

/// A version of `Pipe` that works more uniformly between windows and posix
public class FDPipe {
public let fileHandleForReading: FDFileHandle
public let fileHandleForWriting: FDFileHandle

public init() {
#if os(Windows)
// Adapted from libuv:
// https://github.com/libuv/libuv/blob/34db4c21b1f3182a74091d927b10bb9830ef6717/src/win/pipe.c#L249
let uniquePipeName = "\\\\.\\pipe\\swsh-\(UUID().uuidString)-\(GetCurrentProcessId())"
let pipeName: UnsafeMutablePointer<CChar>? = uniquePipeName.withCString(encodedAs: UTF8.self) { _strdup($0) }
defer { free(pipeName) }
let pipeMode = DWORD(PIPE_TYPE_BYTE) | DWORD(PIPE_READMODE_BYTE) | DWORD(PIPE_WAIT)
let serverAccess =
DWORD(PIPE_ACCESS_INBOUND) |
DWORD(PIPE_ACCESS_OUTBOUND) |
DWORD(WRITE_DAC) |
DWORD(FILE_FLAG_FIRST_PIPE_INSTANCE)
let clientAccess =
DWORD(GENERIC_READ) |
// DWORD(FILE_READ_ATTRIBUTES) |
DWORD(GENERIC_WRITE) |
// DWORD(FILE_WRITE_ATTRIBUTES) |
DWORD(WRITE_DAC)

printOSCall("CreateNamedPipeA", pipeName, serverAccess, pipeMode, 1, 65536, 65536, 0, nil)
let serverPipe = CreateNamedPipeA(
/* lpName */ pipeName,
/* dwOpenMode */ serverAccess,
/* dwPipeMode */ pipeMode,
/* nMaxInstances */ 1,
/* nOutBufferSize */ 65536,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably be a hex literal

/* nInBufferSize */ 65536,
/* nDefaultTimeOut */ 0,
/* lpSecurityAttributes */ nil
)
guard serverPipe != INVALID_HANDLE_VALUE else {
fatalError("Server pipe creation failed with error: \(WindowsSpawnImpl.Error(systemError: GetLastError()))")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This library shouldn't ever crash

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this init should throw since we aren't constrained by Pipe's API

}

var clientSecurityAttributes = SECURITY_ATTRIBUTES()
clientSecurityAttributes.nLength = DWORD(MemoryLayout<SECURITY_ATTRIBUTES>.size)
clientSecurityAttributes.lpSecurityDescriptor = nil
clientSecurityAttributes.bInheritHandle = true
printOSCall("CreateFileA", pipeName, clientAccess, 0, "ptr(\(clientSecurityAttributes))", "OPEN_EXISTING", 0, nil)
let clientPipe = CreateFileA(
/* lpFileName */ pipeName,
/* dwDesiredAccess */ clientAccess,
/* dwShareMode */ 0,
/* lpSecurityAttributes */ &clientSecurityAttributes,
/* dwCreationDisposition */ DWORD(OPEN_EXISTING),
/* dwFlagsAndAttributes */ 0,
/* hTemplateFile */ nil
)
guard clientPipe != INVALID_HANDLE_VALUE else {
fatalError("Client pipe creation failed with error: \(WindowsSpawnImpl.Error(systemError: GetLastError()))")
}

printOSCall("ConnectNamedPipe", serverPipe, nil)
guard ConnectNamedPipe(serverPipe, nil) || GetLastError() == ERROR_PIPE_CONNECTED else {
fatalError("Pipe connection failed with error: \(WindowsSpawnImpl.Error(systemError: GetLastError()))")
}

printOSCall("_open_osfhandle", clientPipe, 0)
let fileDescriptorForReading = FileDescriptor(_open_osfhandle(.init(bitPattern: clientPipe), _O_APPEND))
printOSCall("_open_osfhandle", serverPipe, 0)
let fileDescriptorForWriting = FileDescriptor(_open_osfhandle(.init(bitPattern: serverPipe), _O_APPEND))
fileHandleForReading = FDFileHandle(fileDescriptor: fileDescriptorForReading, closeOnDealloc: true)
fileHandleForWriting = FDFileHandle(fileDescriptor: fileDescriptorForWriting, closeOnDealloc: true)

#else
let pipe = Pipe()
fileHandleForReading = FDFileHandle(
fileDescriptor: FileDescriptor(pipe.fileHandleForReading.fileDescriptor),
handle: pipe.fileHandleForReading,
closeOnDealloc: true
)
fileHandleForWriting = FDFileHandle(
fileDescriptor: FileDescriptor(pipe.fileHandleForWriting.fileDescriptor),
handle: pipe.fileHandleForWriting,
closeOnDealloc: true
)
#endif
}
}
Loading