Add CLI arguments for multi-instance support

Added --db-path and --control-socket arguments to app binary to enable
running multiple instances simultaneously.

Updated marathonctl to use clap with --socket argument for targeting
different instances.

Enables multi-client testing with isolated databases and control sockets.

Refs #131, #132

Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
2025-12-24 11:58:30 +00:00
parent da886452bd
commit a0c13be6d6
5 changed files with 376 additions and 3 deletions

159
crates/app/src/control.rs Normal file
View File

@@ -0,0 +1,159 @@
//! Standalone control socket for engine control
//!
//! This control socket starts at app launch and allows external control
//! of the engine, including starting/stopping networking, before any
//! networking is initialized.
use anyhow::Result;
use bevy::prelude::*;
use libmarathon::{
engine::{EngineBridge, EngineCommand},
networking::{ControlCommand, ControlResponse, SessionId},
};
/// Resource holding the control socket path
#[derive(Resource)]
pub struct ControlSocketPath(pub String);
/// Startup system to launch the control socket server
#[cfg(not(target_os = "ios"))]
#[cfg(debug_assertions)]
pub fn start_control_socket_system(socket_path_res: Res<ControlSocketPath>, bridge: Res<EngineBridge>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixListener;
let socket_path = socket_path_res.0.clone();
info!("Starting control socket at {}", socket_path);
// Clone bridge for the async task
let bridge = bridge.clone();
// Spawn tokio runtime in background thread
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
// Clean up any existing socket
let _ = std::fs::remove_file(&socket_path);
let listener = match UnixListener::bind(&socket_path) {
Ok(l) => {
info!("Control socket listening at {}", socket_path);
l
}
Err(e) => {
error!("Failed to bind control socket: {}", e);
return;
}
};
// Accept connections in a loop
loop {
match listener.accept().await {
Ok((mut stream, _addr)) => {
let bridge = bridge.clone();
tokio::spawn(async move {
// Read command length
let mut len_buf = [0u8; 4];
if let Err(e) = stream.read_exact(&mut len_buf).await {
error!("Failed to read command length: {}", e);
return;
}
let len = u32::from_le_bytes(len_buf) as usize;
// Read command bytes
let mut cmd_buf = vec![0u8; len];
if let Err(e) = stream.read_exact(&mut cmd_buf).await {
error!("Failed to read command: {}", e);
return;
}
// Deserialize command
let command = match ControlCommand::from_bytes(&cmd_buf) {
Ok(cmd) => cmd,
Err(e) => {
error!("Failed to deserialize command: {}", e);
let response = ControlResponse::Error {
error: format!("Failed to deserialize: {}", e),
};
let _ = send_response(&mut stream, response).await;
return;
}
};
info!("Received control command: {:?}", command);
// Handle command
let response = handle_command(command, &bridge).await;
// Send response
if let Err(e) = send_response(&mut stream, response).await {
error!("Failed to send response: {}", e);
}
});
}
Err(e) => {
error!("Failed to accept connection: {}", e);
}
}
}
});
});
}
/// Handle a control command and generate a response
#[cfg(not(target_os = "ios"))]
#[cfg(debug_assertions)]
async fn handle_command(command: ControlCommand, bridge: &EngineBridge) -> ControlResponse {
match command {
ControlCommand::JoinSession { session_code } => {
match SessionId::from_code(&session_code) {
Ok(session_id) => {
bridge.send_command(EngineCommand::StartNetworking {
session_id: session_id.clone(),
});
ControlResponse::Ok {
message: format!("Starting networking with session: {}", session_id),
}
}
Err(e) => ControlResponse::Error {
error: format!("Invalid session code: {}", e),
},
}
}
ControlCommand::LeaveSession => {
bridge.send_command(EngineCommand::StopNetworking);
ControlResponse::Ok {
message: "Stopping networking".to_string(),
}
}
_ => ControlResponse::Error {
error: format!("Command {:?} not yet implemented", command),
},
}
}
/// Send a response back through the Unix socket
#[cfg(not(target_os = "ios"))]
#[cfg(debug_assertions)]
async fn send_response(
stream: &mut tokio::net::UnixStream,
response: ControlResponse,
) -> Result<()> {
use tokio::io::AsyncWriteExt;
let bytes = response.to_bytes()?;
let len = bytes.len() as u32;
stream.write_all(&len.to_le_bytes()).await?;
stream.write_all(&bytes).await?;
stream.flush().await?;
Ok(())
}
// No-op stubs for iOS and release builds
#[cfg(any(target_os = "ios", not(debug_assertions)))]
pub fn start_control_socket_system() {}