Skip to content

Commit ed8c5d8

Browse files
committed
TSCBasic: change the behaviour of exec on Windows
This simultaneously makes `exec` less like `exec` and more like `exec` on POSIX platforms. Replace the use of the ucrt `_execve` in favour of spelling out the implementation inline with alterations. There are multiple reasons that this needs to be done. The concept of `exec` is impossible to map to the process management structure on Windows (just as `fork` is). Fundamentally, `exec` is a replacement of the process image which will retain the process id and parts of the libc state (e.g. non-`cloexec` fds in their current state). However, the process model on Windows does not have the ability to do such an operation. Each process is immutable. `_execve` is a wrapper for `_spawnl` with `_P_OVERLAY` - it simply will create a new process and terminate the existing one. This implicitly breaks the façade - the PID is not inherited - `GetCurrentProcessId()` would return a different value (which would require to be passed from the parent to the child as the parent state will be demolished and there is no lineage that is preserved). Additionally, the new process will only inherit `HANDLE`s which have marked `bInheritable` as `TRUE` at construction time via `CreateFileW`. More importantly, when the `_execve` is used, it firstly inherit the ASCII traits which will further limit the use of this already less-than-useful portability utility. It will limit the file paths even more than the unicode variant, which is already limited by the Win32 subsystem and requires explicit escape via use of NT style paths to work around the Win32 path limitations. Secondly, and more user visible, is the fact that the implementation does not properly hand off the console. The new process is launched in the background and the current process is unceremoniously terminated, restoring control to the command interpreter (cmd.com). The order in which this occurs is unspecified and uncontrollable (i.e. the new process may start before or after the termination). More problematically, this results in two processes with access to the console stdin/stdout/stderr handles, which now creates a problem of who acquires the input. Most often, this is manifested as read by the command interpreter rather than the application, followed by the application rather than the command interpreter. We have effectively re-implemented `_execve` in place here, with a few exceptions: - we do not explicitly enumerate the inheritable handles and pass them via an undocumented handoff to ucrt (if for no other reason than we do not have a good solution to accessing the FD table) - we do not explicitly create the environment block, the normal process inheritance rules apply to the environment. - we do not pass in the first parameter to `CreateProcessW`, which would influence how the process is created (requires that the program suffix is a well-known suffix - .exe, .com, etc). Note that this will prevent the execution of a batch file as that requires that `lpApplicationName` is explicitly set to `cmd.exe` and that the first parameter of the argument string is `/c` rather than the executable path. - we use the unicode variant of the operations to allow us to access the filesystem properly - we create a job object to monitor the subsequent process hierarchy with a silent breakaway, kill-on-close Job Object to ensure that the subprocesses of the "exec"-ed image are treated as part of the same process tree - we now reliably create the process (suspended) and assign it to the job object, and then wait for the process termination before the process exit, preventing the problem of the interrupted execution. While this has limitations in the precise emulation of `exec` as defined by POSIX, it is sufficient to allow execution of subprocesses as desired. This has user-visible differences, e.g. PID and file descriptor states are lost. It has been opined by many others that `fork` and `exec` are a mistake, and it may be a better approach to replace the `exec` call with a `invoke_tool` operation which more precisely matches the usecase and retains the behavioural differences from `exec`. Resolves SR-13806!
1 parent 4afd18e commit ed8c5d8

File tree

1 file changed

+133
-8
lines changed

1 file changed

+133
-8
lines changed

Sources/TSCBasic/misc.swift

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,80 @@
1010

1111
import TSCLibc
1212
import Foundation
13+
#if os(Windows)
14+
import WinSDK
15+
#endif
1316

1417
#if os(Windows)
1518
public let executableFileSuffix = ".exe"
1619
#else
1720
public let executableFileSuffix = ""
1821
#endif
1922

23+
#if os(Windows)
24+
private func quote(_ arguments: [String]) -> String {
25+
func quote(argument: String) -> String {
26+
if !argument.contains(where: { " \t\n\"".contains($0) }) {
27+
return argument
28+
}
29+
30+
// To escape the command line, we surround the argument with quotes.
31+
// However, the complication comes due to how the Windows command line
32+
// parser treats backslashes (\) and quotes (").
33+
//
34+
// - \ is normally treated as a literal backslash
35+
// e.g. alpha\beta\gamma => alpha\beta\gamma
36+
// - The sequence \" is treated as a literal "
37+
// e.g. alpha\"beta => alpha"beta
38+
//
39+
// But then what if we are given a path that ends with a \?
40+
//
41+
// Surrounding alpha\beta\ with " would be "alpha\beta\" which would be
42+
// an unterminated string since it ends on a literal quote. To allow
43+
// this case the parser treats:
44+
//
45+
// - \\" as \ followed by the " metacharacter
46+
// - \\\" as \ followed by a literal "
47+
//
48+
// In general:
49+
// - 2n \ followed by " => n \ followed by the " metacharacter
50+
// - 2n + 1 \ followed by " => n \ followed by a literal "
51+
52+
var quoted = "\""
53+
var unquoted = argument.unicodeScalars
54+
55+
while !unquoted.isEmpty {
56+
guard let firstNonBS = unquoted.firstIndex(where: { $0 != "\\" }) else {
57+
// String ends with a backslash (e.g. first\second\), escape all
58+
// the backslashes then add the metacharacter ".
59+
let count = unquoted.count
60+
quoted.append(String(repeating: "\\", count: 2 * count))
61+
break
62+
}
63+
64+
let count = unquoted.distance(from: unquoted.startIndex, to: firstNonBS)
65+
if unquoted[firstNonBS] == "\"" {
66+
// This is a string of \ followed by a " (e.g. first\"second).
67+
// Escape the backslashes and the quote.
68+
quoted.append(String(repeating: "\\", count: 2 * count + 1))
69+
} else {
70+
// These are just literal backslashes
71+
quoted.append(String(repeating: "\\", count: count))
72+
}
73+
74+
quoted.append(String(unquoted[firstNonBS]))
75+
76+
// Drop the backslashes and the following character
77+
unquoted.removeFirst(count + 1)
78+
}
79+
quoted.append("\"")
80+
81+
return quoted
82+
}
83+
return arguments.map(quote(argument:)).joined(separator: " ")
84+
}
85+
#endif
86+
2087
/// Replace the current process image with a new process image.
2188
///
2289
/// - Parameters:
@@ -25,20 +92,78 @@ public let executableFileSuffix = ""
2592
public func exec(path: String, args: [String]) throws -> Never {
2693
let cArgs = CStringArray(args)
2794
#if os(Windows)
28-
guard cArgs.cArray.withUnsafeBufferPointer({
29-
$0.withMemoryRebound(to: UnsafePointer<Int8>?.self, {
30-
_execv(path, $0.baseAddress) != -1
31-
})
32-
})
33-
else {
34-
throw SystemError.exec(errno, path: path, args: args)
95+
var hJob: HANDLE
96+
97+
hJob = CreateJobObjectA(nil, nil)
98+
if hJob == HANDLE(bitPattern: 0) {
99+
throw SystemError.exec(Int32(GetLastError()), path: path, args: args)
35100
}
101+
defer { CloseHandle(hJob) }
102+
103+
let hPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nil, 0, 1)
104+
if hPort == HANDLE(bitPattern: 0) {
105+
throw SystemError.exec(Int32(GetLastError()), path: path, args: args)
106+
}
107+
108+
var acpAssociation: JOBOBJECT_ASSOCIATE_COMPLETION_PORT = JOBOBJECT_ASSOCIATE_COMPLETION_PORT()
109+
acpAssociation.CompletionKey = hJob
110+
acpAssociation.CompletionPort = hPort
111+
if !SetInformationJobObject(hJob, JobObjectAssociateCompletionPortInformation,
112+
&acpAssociation, DWORD(MemoryLayout<JOBOBJECT_ASSOCIATE_COMPLETION_PORT>.size)) {
113+
throw SystemError.exec(Int32(GetLastError()), path: path, args: args)
114+
}
115+
116+
var eliLimits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
117+
eliLimits.BasicLimitInformation.LimitFlags =
118+
DWORD(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) | DWORD(JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)
119+
if !SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &eliLimits,
120+
DWORD(MemoryLayout<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>.size)) {
121+
throw SystemError.exec(Int32(GetLastError()), path: path, args: args)
122+
}
123+
124+
125+
var siInfo: STARTUPINFOW = STARTUPINFOW()
126+
siInfo.cb = DWORD(MemoryLayout<STARTUPINFOW>.size)
127+
128+
var piInfo: PROCESS_INFORMATION = PROCESS_INFORMATION()
129+
130+
try quote(args).withCString(encodedAs: UTF16.self) { pwszCommandLine in
131+
if !CreateProcessW(nil,
132+
UnsafeMutablePointer<WCHAR>(mutating: pwszCommandLine),
133+
nil, nil, false,
134+
DWORD(CREATE_SUSPENDED) | DWORD(CREATE_NEW_PROCESS_GROUP),
135+
nil, nil, &siInfo, &piInfo) {
136+
throw SystemError.exec(Int32(GetLastError()), path: path, args: args)
137+
}
138+
}
139+
140+
defer { CloseHandle(piInfo.hThread) }
141+
defer { CloseHandle(piInfo.hProcess) }
142+
143+
if !AssignProcessToJobObject(hJob, piInfo.hProcess) {
144+
throw SystemError.exec(Int32(GetLastError()), path: path, args: args)
145+
}
146+
147+
_ = ResumeThread(piInfo.hThread)
148+
149+
var dwCompletionCode: DWORD = 0
150+
var ulCompletionKey: ULONG_PTR = 0
151+
var lpOverlapped: LPOVERLAPPED?
152+
repeat {
153+
} while GetQueuedCompletionStatus(hPort, &dwCompletionCode, &ulCompletionKey,
154+
&lpOverlapped, INFINITE) &&
155+
!(ulCompletionKey == ULONG_PTR(UInt(bitPattern: hJob)) &&
156+
dwCompletionCode == JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO)
157+
158+
var dwExitCode: DWORD = DWORD(bitPattern: -1)
159+
_ = GetExitCodeProcess(piInfo.hProcess, &dwExitCode)
160+
_exit(Int32(bitPattern: dwExitCode))
36161
#elseif (!canImport(Darwin) || os(macOS))
37162
guard execv(path, cArgs.cArray) != -1 else {
38163
throw SystemError.exec(errno, path: path, args: args)
39164
}
40-
#endif
41165
fatalError("unreachable")
166+
#endif
42167
}
43168

44169
@_disfavoredOverload

0 commit comments

Comments
 (0)