diff --git a/Package.swift b/Package.swift index 6ce9491..1cabbe3 100644 --- a/Package.swift +++ b/Package.swift @@ -10,10 +10,18 @@ let package = Package( ], products: [ .executable(name: "runner", targets: ["Runner"]), - .executable(name: "launcher", targets: ["Launcher"]) + .executable(name: "launcher", targets: ["Launcher"]), + .library(name: "MessageProtocol", targets: ["MessageProtocol"]) ], targets: [ - .executableTarget(name: "Runner"), - .executableTarget(name: "Launcher") + .executableTarget( + name: "Runner", + dependencies: ["MessageProtocol"] + ), + .executableTarget( + name: "Launcher", + dependencies: ["MessageProtocol"] + ), + .target(name: "MessageProtocol") ] ) diff --git a/Sources/Launcher/main.swift b/Sources/Launcher/main.swift index 574000f..a3d151c 100644 --- a/Sources/Launcher/main.swift +++ b/Sources/Launcher/main.swift @@ -7,6 +7,7 @@ import Foundation import AppKit +import MessageProtocol func log(_ message: String, error: Bool = false) { fputs("[GameStub Launcher] \(message)\n", error ? stderr : stdout) @@ -80,48 +81,30 @@ func startSocket() -> Int32 { } log("UDS connection accepted") - var lastMessages: [String] = [] - var exitCode: Int32? + var reader = SocketReader(fd: clientSocket) while true { - var buffer: [UInt8] = .init(repeating: 0, count: 16384) - let bytesRead = read(clientSocket, &buffer, buffer.count) - if bytesRead > 0 { - if buffer[0] <= 1 { - if let message: String = .init(bytes: buffer[1..= 10 { - lastMessages.removeFirst() - } - lastMessages.append(message) - let stream: UnsafeMutablePointer = buffer[0] == 0 ? stdout : stderr - fputs(message, stream) - fflush(stream) - } else { - log("Failed to decode UTF-8 message (bytesRead=\(bytesRead))", error: true) - } - } else if buffer[0] == 0xFF { - let exitCodeSize: Int = MemoryLayout.size - guard bytesRead >= 1 + exitCodeSize else { - log("Incomplete exit code message (bytesRead=\(bytesRead))", error: true) - return 1 - } - var decodedExitCode: Int32 = 0 - withUnsafeMutableBytes(of: &decodedExitCode) { destination in - destination.copyBytes(from: buffer[1..<(1 + exitCodeSize)]) - } - exitCode = decodedExitCode - } - } else if bytesRead == 0 { - guard let exitCode else { - log("JVM holder terminated unexpectedly (unexpected socket EOF)", error: true) - return 1 - } - log("Game exited with exit code \(exitCode)") - return exitCode - } else { - perror("read") + let message: Message + do { + message = try .decode(from: &reader) + } catch { + log("Message decode failed: \(error.localizedDescription)", error: true) return 1 } + + switch message { + case .stdout(let content): + _ = content.withUnsafeBytes { fwrite($0.baseAddress, 1, $0.count, stdout) } + fflush(stdout) + case .stderr(let content): + _ = content.withUnsafeBytes { fwrite($0.baseAddress, 1, $0.count, stderr) } + fflush(stderr) + case .exit(let code): + log("Game exited with exit code \(code)") + return code + default: + log("Unexpected message: \(message)", error: true) + } } } @@ -149,7 +132,7 @@ let (socketPath, serverSocket) = makeServerSocket() log("Listening for UDS connections: \(socketPath)") listen(serverSocket, 1) -var runnerArguments: [String] = [ +var runnerArguments = [ "--holder", "--working-directory", FileManager.default.currentDirectoryPath, "--socket-path", socketPath, @@ -158,7 +141,7 @@ var runnerArguments: [String] = [ #if DEBUG_PROCESS_LAUNCH -let process: Process = .init() +let process = Process() process.executableURL = appBundleURL.appending(path: "Contents/MacOS/runner") process.arguments = runnerArguments process.currentDirectoryURL = .init(filePath: FileManager.default.currentDirectoryPath) diff --git a/Sources/MessageProtocol/ByteReader.swift b/Sources/MessageProtocol/ByteReader.swift new file mode 100644 index 0000000..408f98c --- /dev/null +++ b/Sources/MessageProtocol/ByteReader.swift @@ -0,0 +1,55 @@ +// +// ByteReader.swift +// GameStub +// +// Created by AnemoFlower on 2026/6/14. +// + +import Foundation + +/// A byte reader abstraction used by message decoding. +public protocol ByteReader { + func read(_ count: Int) throws -> [UInt8] +} + +/// A byte reader that reads from a Unix Domain Socket file descriptor. +public struct SocketReader: ByteReader { + private let fd: Int32 + + public init(fd: Int32) { + self.fd = fd + } + + public func read(_ count: Int) throws -> [UInt8] { + var buffer = [UInt8](repeating: 0, count: count) + var offset = 0 + + while offset < count { + let bytesRead = buffer.withUnsafeMutableBytes { rawBuffer in + Darwin.read(fd, rawBuffer.baseAddress!.advanced(by: offset), count - offset) + } + if bytesRead == 0 { + throw ReadError.unexpectedEOF + } else if bytesRead < 0 { + throw ReadError.readFailed(message: String(cString: strerror(errno))) + } + offset += bytesRead + } + + return buffer + } + + public enum ReadError: LocalizedError { + case readFailed(message: String) + case unexpectedEOF + + public var errorDescription: String? { + switch self { + case .readFailed(let message): + return "Read failed: \(message)" + case .unexpectedEOF: + return "Unexpected EOF" + } + } + } +} diff --git a/Sources/MessageProtocol/Protocol.swift b/Sources/MessageProtocol/Protocol.swift new file mode 100644 index 0000000..11bf24e --- /dev/null +++ b/Sources/MessageProtocol/Protocol.swift @@ -0,0 +1,91 @@ +// +// Protocol.swift +// GameStub +// +// Created by AnemoFlower on 2026/6/14. +// + +import Foundation + +/// A framed message exchanged between Runner and Launcher. +/// +/// Message format: +/// - 4-byte length prefix +/// - 1-byte message type +/// - payload bytes +public enum Message { + case stdout(content: Data) // 0x00 + case stderr(content: Data) // 0x01 + case stdin(content: Data) // 0x02 + case exit(code: Int32) // 0xFF + + /// Encodes the message into a length-prefixed byte frame. + public func encoded() -> Data { + let type: UInt8 + let body: Data + + switch self { + case .stdout(let content): + type = 0x00 + body = content + case .stderr(let content): + type = 0x01 + body = content + case .stdin(let content): + type = 0x02 + body = content + case .exit(let code): + type = 0xFF + body = withUnsafeBytes(of: code.bigEndian) { Data($0) } + } + + let length: UInt32 = .init(body.count) + + return withUnsafeBytes(of: length.bigEndian) { Data($0) } + [type] + body + } + + /// Decodes a message from a byte reader. + public static func decode(from reader: inout Reader) throws(DecodingError) -> Message { + let header: [UInt8] + do { + header = try reader.read(5) + } catch { throw .readFailed(underlying: error) } + + let length = header[0..<4].withUnsafeBytes { Int($0.load(as: UInt32.self).bigEndian) } + let type: UInt8 = header[4] + guard [0x00, 0x01, 0x02, 0xFF].contains(type) else { throw .unknownType(type) } + + let body: Data + do { + body = Data(try reader.read(length)) + } catch { throw .readFailed(underlying: error) } + + switch type { + case 0x00: return .stdout(content: body) + case 0x01: return .stderr(content: body) + case 0x02: return .stdin(content: body) + case 0xFF: + guard body.count >= 4 else { throw .incomplete(expected: 4, bytesRead: body.count) } + return .exit(code: body[0..<4].withUnsafeBytes { $0.load(as: Int32.self).bigEndian }) + default: throw .unknownType(type) + } + } + + /// Errors thrown while decoding a message. + public enum DecodingError: LocalizedError { + case readFailed(underlying: Error) + case unknownType(_ type: UInt8) + case incomplete(expected: Int, bytesRead: Int) + + public var errorDescription: String? { + switch self { + case .readFailed(let underlying): + return "Failed to read message: \(underlying.localizedDescription)" + case .unknownType(let type): + return "Unknown message type: 0x\(String(type, radix: 16))" + case .incomplete(let expected, let bytesRead): + return "Incomplete message payload: expected \(expected) bytes, got \(bytesRead)" + } + } + } +} diff --git a/Sources/Runner/main.swift b/Sources/Runner/main.swift index d30c5b8..3c937ac 100644 --- a/Sources/Runner/main.swift +++ b/Sources/Runner/main.swift @@ -7,6 +7,7 @@ import Foundation import AppKit +import MessageProtocol if CommandLine.arguments.count == 1 { let alert: NSAlert = .init() @@ -87,14 +88,14 @@ func connectSocket(at path: String) -> Int32? { return sockfd } -func send(data: Data, to sockfd: Int32, type: UInt8, queue: DispatchQueue, completion: (@Sendable () -> Void)? = nil) { - let payload: Data = [type] + data +func send(_ message: Message, to sockfd: Int32, queue: DispatchQueue, completion: (@Sendable () -> Void)? = nil) { + let data = message.encoded() queue.async { - payload.withUnsafeBytes { + data.withUnsafeBytes { let base = $0.baseAddress?.assumingMemoryBound(to: UInt8.self) - var written: Int = 0 - while written < payload.count { - let rc = write(sockfd, base! + written, payload.count - written) + var written = 0 + while written < data.count { + let rc = write(sockfd, base! + written, data.count - written) if rc <= 0 { break } written += rc } @@ -107,9 +108,11 @@ func handlePipe(_ pipe: Pipe, to sockfd: Int32, error: Bool, queue: DispatchQueu let handle: FileHandle = pipe.fileHandleForReading DispatchQueue.global().async { while true { - let data: Data = handle.availableData + let data = handle.availableData if data.isEmpty { break } - send(data: data, to: sockfd, type: error ? 1 : 0, queue: queue) + + let message: Message = error ? .stderr(content: data) : .stdout(content: data) + send(message, to: sockfd, queue: queue) } } } @@ -145,8 +148,7 @@ if holder { process.standardError = errorPipe process.terminationHandler = { process in - let data: Data = withUnsafeBytes(of: process.terminationStatus) { Data($0) } - send(data: data, to: sockfd, type: 0xFF, queue: socketQueue) { + send(.exit(code: process.terminationStatus), to: sockfd, queue: socketQueue) { exit(process.terminationStatus) } }