Skip to content

Add launcher binary that allows for debugging missing DLL dependencies at load time #657

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
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
267 changes: 267 additions & 0 deletions Sources/swblauncher/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import WinSDK
import struct Foundation.Data
import class Foundation.FileHandle
import struct Foundation.URL

// Also see winternl.h in the Windows SDK for the definitions of a number of these structures.

// https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntqueryinformationprocess#parameters
fileprivate let ProcessBasicInformation: CInt = 0

// https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/show-loader-snaps
fileprivate let FLG_SHOW_LOADER_SNAPS: ULONG = 0x2

// https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntqueryinformationprocess#process_basic_information
fileprivate struct PROCESS_BASIC_INFORMATION {
var ExitStatus: NTSTATUS = 0
var PebBaseAddress: ULONG_PTR = 0
var AffinityMask: ULONG_PTR = 0
var BasePriority: LONG = 0
var UniqueProcessId: ULONG_PTR = 0
var InheritedFromUniqueProcessId: ULONG_PTR = 0
}

// https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntqueryinformationprocess
fileprivate typealias NtQueryInformationProcessFunction = @convention(c) (_ ProcessHandle: HANDLE, _ ProcessInformationClass: CInt, _ ProcessInformation: PVOID, _ ProcessInformationLength: ULONG, _ ReturnLength: PULONG) -> NTSTATUS

fileprivate struct _Win32Error: Error {
let functionName: String
let error: DWORD
}

fileprivate nonisolated var KF_FLAG_DEFAULT: DWORD {
DWORD(WinSDK.KF_FLAG_DEFAULT.rawValue)
}

fileprivate func SUCCEEDED(_ hr: HRESULT) -> Bool {
hr >= 0
}

fileprivate func _url(for id: KNOWNFOLDERID) throws -> URL {
var pszPath: PWSTR?
let hr: HRESULT = withUnsafePointer(to: id) { id in
SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, nil, &pszPath)
}
guard SUCCEEDED(hr) else { throw _Win32Error(functionName: "SHGetKnownFolderPath", error: GetLastError()) }
defer { CoTaskMemFree(pszPath) }
return URL(filePath: String(decodingCString: pszPath!, as: UTF16.self), directoryHint: .isDirectory)
}

extension String {
fileprivate func withLPWSTR<T>(_ body: (UnsafeMutablePointer<WCHAR>) throws -> T) rethrows -> T {
try withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: self.utf16.count + 1, { outBuffer in
try self.withCString(encodedAs: UTF16.self) { inBuffer in
outBuffer.baseAddress!.initialize(from: inBuffer, count: self.utf16.count)
outBuffer[outBuffer.count - 1] = 0
return try body(outBuffer.baseAddress!)
}
})
}
}

extension PROCESS_BASIC_INFORMATION {
fileprivate init(_ hProcess: HANDLE, _ NtQueryInformation: NtQueryInformationProcessFunction) throws {
self.init()

let processBasicInformationSize = MemoryLayout.size(ofValue: self)
#if arch(x86_64) || arch(arm64)
precondition(processBasicInformationSize == 48)
#elseif arch(i386) || arch(arm)
precondition(processBasicInformationSize == 24)
#else
#error("Unsupported architecture")
#endif

var len: ULONG = 0
guard NtQueryInformation(hProcess, ProcessBasicInformation, &self, ULONG(processBasicInformationSize), &len) == 0 else {
throw _Win32Error(functionName: "NtQueryInformationProcess", error: GetLastError())
}
}

// FIXME: Does this work for mixed architecture scenarios? WoW64 seems to be OK.
fileprivate var PebBaseAddress_NtGlobalFlag: ULONG_PTR {
#if arch(x86_64) || arch(arm64)
PebBaseAddress + 0xBC // https://github.com/wine-mirror/wine/blob/e1af2ae201c9853133ef3af1dafe15fe992fed92/include/winternl.h#L990 (undocumented officially)
#elseif arch(i386) || arch(arm)
PebBaseAddress + 0x68 // https://github.com/wine-mirror/wine/blob/e1af2ae201c9853133ef3af1dafe15fe992fed92/include/winternl.h#L880 (undocumented officially)
#else
#error("Unsupported architecture")
#endif
}
}

fileprivate func withGFlags(_ hProcess: HANDLE, _ ProcessBasicInformation: PROCESS_BASIC_INFORMATION, _ block: (_ gflags: inout ULONG) -> ()) throws {
// https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/gflags-flag-table
var gflags: ULONG = 0
var actual: SIZE_T = 0
guard ReadProcessMemory(hProcess, UnsafeMutableRawPointer(bitPattern: Int(ProcessBasicInformation.PebBaseAddress_NtGlobalFlag)), &gflags, SIZE_T(MemoryLayout.size(ofValue: gflags)), &actual) else {
throw _Win32Error(functionName: "ReadProcessMemory", error: GetLastError())
}

block(&gflags)
guard WriteProcessMemory(hProcess, UnsafeMutableRawPointer(bitPattern: Int(ProcessBasicInformation.PebBaseAddress_NtGlobalFlag)), &gflags, SIZE_T(MemoryLayout.size(ofValue: gflags)), &actual) else {
throw _Win32Error(functionName: "WriteProcessMemory", error: GetLastError())
}
}

func withDebugEventLoop(_ hProcess: HANDLE, _ handle: (_ event: String) throws -> ()) throws {
let ntdllURL = try _url(for: FOLDERID_System).appendingPathComponent("ntdll.dll")
guard let ntdll = ntdllURL.withUnsafeFileSystemRepresentation({ s in s.map({ String(cString: $0) }) ?? String() }).withLPWSTR({ LoadLibraryW($0) }) else {
throw _Win32Error(functionName: "LoadLibraryW", error: GetLastError())
}

defer {
_ = FreeLibrary(ntdll)
}

guard let ntQueryInformationProc = GetProcAddress(ntdll, "NtQueryInformationProcess") else {
throw _Win32Error(functionName: "GetProcAddress", error: GetLastError())
}

let processBasicInformation = try PROCESS_BASIC_INFORMATION(hProcess, unsafeBitCast(ntQueryInformationProc, to: NtQueryInformationProcessFunction.self))

try withGFlags(hProcess, processBasicInformation) { gflags in
gflags |= FLG_SHOW_LOADER_SNAPS
}

func debugOutputString(_ hProcess: HANDLE, _ dbgEvent: inout DEBUG_EVENT) throws -> String {
let size = SIZE_T(dbgEvent.u.DebugString.nDebugStringLength)
return try withUnsafeTemporaryAllocation(of: UInt8.self, capacity: Int(size) + 2) { buffer in
guard ReadProcessMemory(hProcess, dbgEvent.u.DebugString.lpDebugStringData, buffer.baseAddress, size, nil) else {
throw _Win32Error(functionName: "ReadProcessMemory", error: GetLastError())
}

buffer[Int(size)] = 0
buffer[Int(size + 1)] = 0

if dbgEvent.u.DebugString.fUnicode != 0 {
return buffer.withMemoryRebound(to: UInt16.self) { String(decoding: $0, as: UTF16.self) }
} else {
return try withUnsafeTemporaryAllocation(of: UInt16.self, capacity: Int(size)) { wideBuffer in
if MultiByteToWideChar(UINT(CP_ACP), 0, buffer.baseAddress, Int32(size), wideBuffer.baseAddress, Int32(size)) == 0 {
throw _Win32Error(functionName: "MultiByteToWideChar", error: GetLastError())
}
return String(decoding: wideBuffer, as: UTF16.self)
}
}
}
}

func _WaitForDebugEventEx() throws -> DEBUG_EVENT {
// WARNING: Only the thread that created the process being debugged can call WaitForDebugEventEx.
var dbgEvent = DEBUG_EVENT()
guard WaitForDebugEventEx(&dbgEvent, INFINITE) else {
// WaitForDebugEventEx will fail if dwCreationFlags did not contain DEBUG_ONLY_THIS_PROCESS
throw _Win32Error(functionName: "WaitForDebugEventEx", error: GetLastError())
}
return dbgEvent
}

func runDebugEventLoop() throws {
do {
while true {
var dbgEvent = try _WaitForDebugEventEx()
if dbgEvent.dwProcessId == GetProcessId(hProcess) {
switch dbgEvent.dwDebugEventCode {
case DWORD(OUTPUT_DEBUG_STRING_EVENT):
try handle(debugOutputString(hProcess, &dbgEvent))
case DWORD(EXIT_PROCESS_DEBUG_EVENT):
return // done!
default:
break
}
}

guard ContinueDebugEvent(dbgEvent.dwProcessId, dbgEvent.dwThreadId, DBG_EXCEPTION_NOT_HANDLED) else {
throw _Win32Error(functionName: "WaitForDebugEventEx", error: GetLastError())
}
}
} catch {
throw error
}
}

try runDebugEventLoop()
}

func createProcessTrampoline(_ commandLine: String) throws -> Int32 {
var processInformation = PROCESS_INFORMATION()
guard commandLine.withLPWSTR({ wCommandLine in
var startupInfo = STARTUPINFOW()
startupInfo.cb = DWORD(MemoryLayout.size(ofValue: startupInfo))
return CreateProcessW(
nil,
wCommandLine,
nil,
nil,
false,
DWORD(DEBUG_ONLY_THIS_PROCESS),
nil,
nil,
&startupInfo,
&processInformation,
)
}) else {
throw _Win32Error(functionName: "CreateProcessW", error: GetLastError())
}
defer {
_ = CloseHandle(processInformation.hThread)
_ = CloseHandle(processInformation.hProcess)
}
var missingDLLs: [String] = []
try withDebugEventLoop(processInformation.hProcess) { message in
if let match = try #/ ERROR: Unable to load DLL: "(?<moduleName>.*?)",/#.firstMatch(in: message) {
missingDLLs.append(String(match.output.moduleName))
}
}
// Don't need to call WaitForSingleObject because the process will have exited after withDebugEventLoop is called
var exitCode: DWORD = .max
guard GetExitCodeProcess(processInformation.hProcess, &exitCode) else {
throw _Win32Error(functionName: "GetExitCodeProcess", error: GetLastError())
}
if exitCode == STATUS_DLL_NOT_FOUND {
let stderr = FileHandle.standardError
for missingDLL in missingDLLs {
try stderr.write(contentsOf: Data("This application has failed to start because \(missingDLL) was not found.\r\n".utf8))
}
}
return Int32(bitPattern: exitCode)
}

func main() -> Int32 {
do {
var commandLine = String(decodingCString: GetCommandLineW(), as: UTF16.self)

// FIXME: This could probably be more robust
if commandLine.first == "\"" {
commandLine = String(commandLine.dropFirst())
if let index = commandLine.firstIndex(of: "\"") {
commandLine = String(commandLine.dropFirst(commandLine.distance(from: commandLine.startIndex, to: index) + 2))
}
} else if let index = commandLine.firstIndex(of: " ") {
commandLine = String(commandLine.dropFirst(commandLine.distance(from: commandLine.startIndex, to: index) + 1))
} else {
commandLine = ""
}

return try createProcessTrampoline(commandLine)
} catch {
let stderr = FileHandle.standardError
try? stderr.write(contentsOf: Data("\(error)\r\n".utf8))
return EXIT_FAILURE
}
}

exit(main())