Skip to content
Draft
Show file tree
Hide file tree
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
14 changes: 11 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
]
)
63 changes: 23 additions & 40 deletions Sources/Launcher/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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..<bytesRead], encoding: .utf8) {
if lastMessages.count >= 10 {
lastMessages.removeFirst()
}
lastMessages.append(message)
let stream: UnsafeMutablePointer<FILE> = 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<Int32>.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)
}
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions Sources/MessageProtocol/ByteReader.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
91 changes: 91 additions & 0 deletions Sources/MessageProtocol/Protocol.swift
Original file line number Diff line number Diff line change
@@ -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<Reader: ByteReader>(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)"
}
}
}
}
22 changes: 12 additions & 10 deletions Sources/Runner/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Foundation
import AppKit
import MessageProtocol

if CommandLine.arguments.count == 1 {
let alert: NSAlert = .init()
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down