779 lines
28 KiB
Rust
779 lines
28 KiB
Rust
//! FIFA 17 local companion services — LSX (Origin emulator) + autopatch
|
|
//! (ProtoSSL cert-verify memory patcher). Both are inherently local to the game
|
|
//! machine and are managed by the launcher as child processes, mirroring the way
|
|
//! `setup::launch_game` spawns and log-streams the game.
|
|
//!
|
|
//! WHY THESE TWO ARE LOCAL (and the rest is not): the heavy FUT responders
|
|
//! (Blaze / UTAS / roster / POW) run in the server container. LSX must stay here
|
|
//! because the game dials it on the hardcoded loopback `127.0.0.1:4216`;
|
|
//! autopatch must stay here because it writes `/proc/<FIFA17.exe>/mem`.
|
|
//!
|
|
//! Lifecycle: each service is a long-running daemon. We keep the `Child` handle
|
|
//! so the UI can show running/stopped and stop them. Both run as the launcher
|
|
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
|
|
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
|
|
|
|
use parking_lot::Mutex;
|
|
use std::{
|
|
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
|
path::{Path, PathBuf},
|
|
process::{Child, Command, Stdio},
|
|
sync::{mpsc, Arc},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use std::os::unix::process::CommandExt;
|
|
|
|
use crate::fifa17_capability::{
|
|
parse_capability_line, parse_fifa_pid, register, Fifa17ClientCapabilities,
|
|
};
|
|
use crate::logs::LogBuffer;
|
|
|
|
/// The loopback endpoint LSX must own. FIFA dials this exact address and nothing
|
|
/// else, so "is LSX ready?" is answerable without asking LSX anything.
|
|
pub const LSX_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct CommandParts {
|
|
program: String,
|
|
args: Vec<String>,
|
|
}
|
|
|
|
/// Which companion service. The `str` values are used in log prefixes.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub enum Service {
|
|
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
|
Lsx,
|
|
/// autopatch — patches FIFA17.exe process memory after host ptrace arming.
|
|
Autopatch,
|
|
}
|
|
|
|
impl Service {
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Service::Lsx => "LSX",
|
|
Service::Autopatch => "autopatch",
|
|
}
|
|
}
|
|
|
|
/// The companion's executable name.
|
|
///
|
|
/// These were Python responder scripts run through a configured interpreter. They
|
|
/// are now Rust binaries built from this workspace (`openfut-lsx`,
|
|
/// `openfut-autopatch`), which removes the interpreter and the tools directory
|
|
/// from the launch contract entirely: no `python3` to locate, no script path to
|
|
/// configure, and no chance of running a stale checkout's copy.
|
|
fn binary(self) -> &'static str {
|
|
match self {
|
|
Service::Lsx => "openfut-lsx",
|
|
Service::Autopatch => "openfut-autopatch",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Absolute path to a companion binary.
|
|
///
|
|
/// Prefers a sibling of the running launcher, which is what a workspace build and any
|
|
/// sane install layout both produce, and falls back to the bare name so a
|
|
/// PATH-installed binary still works. Returning the bare name rather than failing
|
|
/// keeps `spawn` responsible for reporting a missing binary, with one error message
|
|
/// instead of two.
|
|
fn resolve_binary(service: Service) -> PathBuf {
|
|
let name = service.binary();
|
|
if let Some(dir) = std::env::current_exe()
|
|
.ok()
|
|
.and_then(|p| p.parent().map(Path::to_path_buf))
|
|
{
|
|
let sibling = dir.join(name);
|
|
if sibling.is_file() {
|
|
return sibling;
|
|
}
|
|
}
|
|
PathBuf::from(name)
|
|
}
|
|
|
|
fn command_parts(service: Service) -> CommandParts {
|
|
let mut args = Vec::new();
|
|
if service == Service::Autopatch {
|
|
// autopatch exits when the launcher does, so it cannot outlive its owner and
|
|
// keep writing to a client the launcher no longer manages.
|
|
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
|
|
}
|
|
CommandParts {
|
|
program: resolve_binary(service).to_string_lossy().into_owned(),
|
|
args,
|
|
}
|
|
}
|
|
|
|
fn dispatch_stop_work<F>(work: F) -> mpsc::Receiver<anyhow::Result<()>>
|
|
where
|
|
F: FnOnce() -> anyhow::Result<()> + Send + 'static,
|
|
{
|
|
let (send, receive) = mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let _ = send.send(work());
|
|
});
|
|
receive
|
|
}
|
|
|
|
fn wait_for_listener_ready(
|
|
child: &mut Child,
|
|
address: SocketAddr,
|
|
timeout: Duration,
|
|
) -> anyhow::Result<()> {
|
|
let deadline = Instant::now() + timeout;
|
|
// Let immediate startup/bind errors surface before accepting an occupied
|
|
// port as evidence that this child became ready.
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
loop {
|
|
if let Some(status) = child
|
|
.try_wait()
|
|
.map_err(|error| anyhow::anyhow!("could not inspect LSX startup: {error}"))?
|
|
{
|
|
anyhow::bail!("LSX exited before becoming ready ({status}); port 4216 may be in use");
|
|
}
|
|
match TcpListener::bind(address) {
|
|
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => return Ok(()),
|
|
Err(error) => anyhow::bail!("could not probe LSX listener {address}: {error}"),
|
|
Ok(listener) => drop(listener),
|
|
}
|
|
if Instant::now() >= deadline {
|
|
anyhow::bail!(
|
|
"LSX did not bind {address} within {} ms",
|
|
timeout.as_millis()
|
|
);
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
/// A managed companion service process.
|
|
#[derive(Default)]
|
|
pub struct ManagedService {
|
|
child: Option<Child>,
|
|
stopping: Option<mpsc::Receiver<anyhow::Result<()>>>,
|
|
}
|
|
|
|
impl ManagedService {
|
|
/// Wrap an already-spawned child.
|
|
pub fn from_child(child: Child) -> Self {
|
|
Self {
|
|
child: Some(child),
|
|
stopping: None,
|
|
}
|
|
}
|
|
|
|
/// True while the child is spawned and has not yet exited. Reaps the exit
|
|
/// status if it has, so the UI reflects a service that died on its own.
|
|
pub fn running(&mut self, log: &Arc<Mutex<LogBuffer>>, label: &str) -> bool {
|
|
if let Some(result) = self.stopping.as_ref() {
|
|
match result.try_recv() {
|
|
Ok(Ok(())) => {
|
|
log.lock().push(format!("[launcher] {label} stopped."));
|
|
self.stopping = None;
|
|
return false;
|
|
}
|
|
Ok(Err(error)) => {
|
|
log.lock()
|
|
.push(format!("[launcher] failed to stop {label}: {error}"));
|
|
self.stopping = None;
|
|
return false;
|
|
}
|
|
Err(mpsc::TryRecvError::Empty) => return true,
|
|
Err(mpsc::TryRecvError::Disconnected) => {
|
|
log.lock().push(format!(
|
|
"[launcher] {label} stop worker exited unexpectedly."
|
|
));
|
|
self.stopping = None;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
match self.child.as_mut() {
|
|
None => false,
|
|
Some(c) => match c.try_wait() {
|
|
Ok(None) => true,
|
|
Ok(Some(status)) => {
|
|
log.lock()
|
|
.push(format!("[launcher] {label} exited ({status})."));
|
|
self.child = None;
|
|
false
|
|
}
|
|
Err(_) => true,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn stopping(&self) -> bool {
|
|
self.stopping.is_some()
|
|
}
|
|
|
|
/// PID of the child this launcher owns, if it owns one.
|
|
pub fn pid(&self) -> Option<u32> {
|
|
self.child.as_ref().map(Child::id)
|
|
}
|
|
|
|
/// Begin stopping the service without waiting on the egui UI thread.
|
|
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
|
|
if self.stopping.is_some() {
|
|
return;
|
|
}
|
|
if let Some(mut child) = self.child.take() {
|
|
let label = service.label();
|
|
log.lock().push(format!("[launcher] stopping {label}…"));
|
|
|
|
self.stopping = Some(dispatch_stop_work(move || {
|
|
child
|
|
.kill()
|
|
.map_err(|error| anyhow::anyhow!("kill failed: {error}"))?;
|
|
|
|
child
|
|
.wait()
|
|
.map_err(|error| anyhow::anyhow!("reap failed: {error}"))?;
|
|
Ok(())
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for ManagedService {
|
|
fn drop(&mut self) {
|
|
if let Some(mut c) = self.child.take() {
|
|
let _ = c.kill();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Backend-registration wiring handed to the autopatch stdout reader so a
|
|
/// verified resolver-guard line can advertise the per-FIFA-process capability to
|
|
/// the backend. `Some(..)` for autopatch; `None` for LSX.
|
|
pub struct CapabilityWiring {
|
|
pub server_host: String,
|
|
pub account_sync_port: u16,
|
|
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
|
|
}
|
|
|
|
/// What is actually true about one companion service right now.
|
|
///
|
|
/// Deliberately observed, never remembered: a button press is not evidence that
|
|
/// a service is up, and a service that died on its own must not keep showing
|
|
/// green because the launcher once started it successfully.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct ServiceRuntime {
|
|
pub running: bool,
|
|
/// True only while THIS launcher owns the live process. Decides whether
|
|
/// cleanup is allowed to touch it: a service someone started by hand for a
|
|
/// debugging session must survive a launch/exit cycle.
|
|
pub started_by_launcher: bool,
|
|
pub pid: Option<u32>,
|
|
/// Observed supporting detail for the Advanced panel. Only ever facts the
|
|
/// launcher actually established.
|
|
pub detail: Option<String>,
|
|
}
|
|
|
|
impl ServiceRuntime {
|
|
/// Whether this service is usable for a launch, as opposed to merely alive.
|
|
/// For LSX that means the port FIFA dials is genuinely held.
|
|
pub fn ready(&self) -> bool {
|
|
self.running
|
|
}
|
|
}
|
|
|
|
/// True when something holds LSX's fixed loopback port.
|
|
pub fn lsx_port_busy() -> bool {
|
|
match TcpListener::bind(LSX_ADDR) {
|
|
Err(error) => error.kind() == std::io::ErrorKind::AddrInUse,
|
|
Ok(listener) => {
|
|
drop(listener);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// PID of a process running `service`'s companion binary that this launcher does
|
|
/// not own, if there is one.
|
|
///
|
|
/// Scans `/proc` — no extra dependency, no privilege, and no guessing: a service
|
|
/// left running by a previous launcher instance or started by hand from a shell
|
|
/// is a real state the UI has to be able to report, and cleanup has to respect.
|
|
///
|
|
/// Matches argv entries rather than `comm`, because `comm` is truncated to 15
|
|
/// characters by the kernel and would misreport these names.
|
|
pub fn foreign_pid(service: Service, ours: Option<u32>) -> Option<u32> {
|
|
let binary = service.binary();
|
|
let self_pid = std::process::id();
|
|
let entries = std::fs::read_dir("/proc").ok()?;
|
|
for entry in entries.flatten() {
|
|
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
|
|
continue;
|
|
};
|
|
if pid == self_pid || Some(pid) == ours {
|
|
continue;
|
|
}
|
|
let Ok(cmdline) = std::fs::read(entry.path().join("cmdline")) else {
|
|
continue;
|
|
};
|
|
if cmdline.split(|b| *b == 0).any(|arg| {
|
|
// Compare the file name, so `/path/to/openfut-lsx` matches while an
|
|
// unrelated argument that merely ends with the same text does not.
|
|
Path::new(&*String::from_utf8_lossy(arg))
|
|
.file_name()
|
|
.is_some_and(|n| n == binary)
|
|
}) {
|
|
return Some(pid);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Whether a stop request may touch this service.
|
|
///
|
|
/// Pure, so the ownership rule is testable without a process: refusing to kill
|
|
/// something the launcher did not start is the whole reason ownership is tracked,
|
|
/// and it must not depend on what happens to be running on the test machine.
|
|
pub fn stop_permitted(runtime: &ServiceRuntime, label: &str) -> Result<(), String> {
|
|
if runtime.running && !runtime.started_by_launcher {
|
|
return Err(format!(
|
|
"{label} was started outside this launcher{} — stop it where it was started.",
|
|
match runtime.pid {
|
|
Some(pid) => format!(" (pid {pid})"),
|
|
None => String::new(),
|
|
}
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Owns both companion services and answers "what is running, and who started
|
|
/// it?" for the whole launcher.
|
|
///
|
|
/// Exists so the launch sequence and the Advanced panel act on the same objects.
|
|
/// Two independent copies of that state is how a UI ends up claiming Ready while
|
|
/// the process is dead.
|
|
pub struct ServiceSupervisor {
|
|
lsx: ManagedService,
|
|
autopatch: ManagedService,
|
|
log: Arc<Mutex<LogBuffer>>,
|
|
}
|
|
|
|
/// Whether [`ServiceSupervisor::ensure_running`] had to do anything.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Ensured {
|
|
/// Already up — left strictly alone.
|
|
Reused,
|
|
Started,
|
|
}
|
|
|
|
impl ServiceSupervisor {
|
|
pub fn new(log: Arc<Mutex<LogBuffer>>) -> Self {
|
|
Self {
|
|
lsx: ManagedService::default(),
|
|
autopatch: ManagedService::default(),
|
|
log,
|
|
}
|
|
}
|
|
|
|
fn slot(&mut self, service: Service) -> &mut ManagedService {
|
|
match service {
|
|
Service::Lsx => &mut self.lsx,
|
|
Service::Autopatch => &mut self.autopatch,
|
|
}
|
|
}
|
|
|
|
/// Observe one service: our own child first, then any foreign instance.
|
|
pub fn observe(&mut self, service: Service) -> ServiceRuntime {
|
|
let log = Arc::clone(&self.log);
|
|
let slot = self.slot(service);
|
|
if slot.stopping() {
|
|
return ServiceRuntime {
|
|
running: true,
|
|
started_by_launcher: true,
|
|
pid: None,
|
|
detail: Some("stopping".into()),
|
|
};
|
|
}
|
|
let ours = slot.pid();
|
|
if slot.running(&log, service.label()) {
|
|
let mut runtime = ServiceRuntime {
|
|
running: true,
|
|
started_by_launcher: true,
|
|
pid: ours,
|
|
detail: None,
|
|
};
|
|
if service == Service::Lsx {
|
|
runtime.detail = Some(if lsx_port_busy() {
|
|
format!("holding {LSX_ADDR}")
|
|
} else {
|
|
// Alive but not listening: real, and not "ready".
|
|
runtime.running = false;
|
|
format!("process alive but {LSX_ADDR} is not held")
|
|
});
|
|
}
|
|
return runtime;
|
|
}
|
|
|
|
match foreign_pid(service, ours) {
|
|
Some(pid) => ServiceRuntime {
|
|
running: true,
|
|
started_by_launcher: false,
|
|
pid: Some(pid),
|
|
detail: Some("started outside this launcher".into()),
|
|
},
|
|
None if service == Service::Lsx && lsx_port_busy() => ServiceRuntime {
|
|
running: false,
|
|
started_by_launcher: false,
|
|
pid: None,
|
|
detail: Some(format!("{LSX_ADDR} is held by an unrelated process")),
|
|
},
|
|
None => ServiceRuntime::default(),
|
|
}
|
|
}
|
|
|
|
/// Start `service` only if it is not already usable. Never restarts a healthy
|
|
/// service, and never adopts a foreign one as ours.
|
|
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
|
|
let runtime = self.observe(service);
|
|
if runtime.ready() {
|
|
self.log.lock().push(format!(
|
|
"[launcher] {} already running{} — reusing it.",
|
|
service.label(),
|
|
match runtime.pid {
|
|
Some(pid) => format!(" (pid {pid})"),
|
|
None => String::new(),
|
|
}
|
|
));
|
|
return Ok(Ensured::Reused);
|
|
}
|
|
if let Some(detail) = runtime.detail.filter(|_| !runtime.running) {
|
|
// No service-name prefix: every caller already renders the service it
|
|
// asked about, and the launch card would print "LSX: LSX: …".
|
|
return Err(detail);
|
|
}
|
|
let child = spawn(
|
|
service,
|
|
spec.persona_id,
|
|
&spec.persona_name,
|
|
spec.capability,
|
|
Arc::clone(&self.log),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
*self.slot(service) = ManagedService::from_child(child);
|
|
Ok(Ensured::Started)
|
|
}
|
|
|
|
/// Stop a service the launcher owns. A foreign process is reported, never
|
|
/// killed: the launcher did not start it and does not know who needs it.
|
|
pub fn stop(&mut self, service: Service) -> Result<(), String> {
|
|
let runtime = self.observe(service);
|
|
stop_permitted(&runtime, service.label())?;
|
|
let log = Arc::clone(&self.log);
|
|
self.slot(service).stop(&log, service);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn stopping(&mut self, service: Service) -> bool {
|
|
self.slot(service).stopping()
|
|
}
|
|
}
|
|
|
|
/// Everything [`spawn`] needs, bundled so the launch sequence can hand it over
|
|
/// as one value per service.
|
|
pub struct SpawnSpec {
|
|
pub persona_id: u64,
|
|
pub persona_name: String,
|
|
pub capability: Option<CapabilityWiring>,
|
|
}
|
|
|
|
/// Spawn a companion service and stream its stdout+stderr into `log`.
|
|
///
|
|
/// Returns an error without spawning if the binary is missing, which is the only
|
|
/// precondition left now that the companions are workspace binaries rather than
|
|
/// Python scripts run from a configured tools directory.
|
|
///
|
|
/// `capability` is the backend-registration wiring + shared per-FIFA-process
|
|
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
|
|
/// verified resolver guard) and `None` for LSX.
|
|
pub fn spawn(
|
|
service: Service,
|
|
persona_id: u64,
|
|
persona_name: &str,
|
|
capability: Option<CapabilityWiring>,
|
|
log: Arc<Mutex<LogBuffer>>,
|
|
) -> anyhow::Result<Child> {
|
|
use std::io::{BufRead, BufReader};
|
|
|
|
let label = service.label();
|
|
let parts = command_parts(service);
|
|
let program = Path::new(&parts.program);
|
|
// Only a resolved absolute path can be checked up front; a bare name is left to
|
|
// the OS to resolve through PATH, and a failure there is reported by spawn below.
|
|
if program.is_absolute() && !program.is_file() {
|
|
anyhow::bail!(
|
|
"{label} binary not found: {} — build the workspace so it sits beside the launcher",
|
|
program.display()
|
|
);
|
|
}
|
|
|
|
let mut cmd = Command::new(&parts.program);
|
|
cmd.args(&parts.args);
|
|
if service == Service::Lsx {
|
|
// The persona LSX reports has to equal what Blaze returns in
|
|
// LoginResponse.SESS.PDTL and what UTAS serves as userInfo.personaId; the
|
|
// constraint is cross-layer agreement, not any particular value.
|
|
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
|
|
.env("FUT_PERSONA_NAME", persona_name);
|
|
} else if service == Service::Autopatch {
|
|
// A per-user runtime log, so a stale root-owned /tmp file cannot block startup.
|
|
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join("openfut-autopatch.log");
|
|
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
|
|
}
|
|
// Put each companion in its own process group for lifecycle isolation.
|
|
cmd.process_group(0);
|
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
|
|
|
log.lock().push(format!(
|
|
"[launcher] starting {label}: {}{}",
|
|
parts.program,
|
|
parts.args.iter().fold(String::new(), |mut acc, a| {
|
|
acc.push(' ');
|
|
acc.push_str(a);
|
|
acc
|
|
}),
|
|
));
|
|
|
|
let mut child = cmd
|
|
.spawn()
|
|
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.binary()))?;
|
|
|
|
if let Some(out) = child.stdout.take() {
|
|
let buf = Arc::clone(&log);
|
|
let lbl = label.to_string();
|
|
// Only autopatch carries capability wiring; LSX passes `None`.
|
|
let cap_wiring = capability;
|
|
let cap_persona = persona_id;
|
|
std::thread::spawn(move || {
|
|
// Fires the backend registration at most once per FIFA process.
|
|
let mut registered = false;
|
|
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
|
// Every raw line is still mirrored into the log, as before.
|
|
buf.lock().push(format!("[{lbl}] {line}"));
|
|
|
|
let Some(wiring) = cap_wiring.as_ref() else {
|
|
continue;
|
|
};
|
|
if registered {
|
|
continue;
|
|
}
|
|
let Some(version) = parse_capability_line(&line) else {
|
|
continue;
|
|
};
|
|
registered = true;
|
|
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
|
|
wiring.sink.lock().empty_mypacks_resolver = Some(version);
|
|
{
|
|
let mut log = buf.lock();
|
|
log.push(format!(
|
|
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
|
|
));
|
|
log.push(format!(
|
|
"[fifa17] registering capability for session (persona {cap_persona})"
|
|
));
|
|
}
|
|
match register(
|
|
&wiring.server_host,
|
|
wiring.account_sync_port,
|
|
cap_persona,
|
|
fifa_pid,
|
|
version,
|
|
) {
|
|
Ok(()) => buf
|
|
.lock()
|
|
.push("[fifa17] capability registered with backend".to_string()),
|
|
Err(error) => buf
|
|
.lock()
|
|
.push(format!("[fifa17] capability registration failed: {error}")),
|
|
}
|
|
}
|
|
});
|
|
}
|
|
if let Some(err) = child.stderr.take() {
|
|
let buf = Arc::clone(&log);
|
|
let lbl = label.to_string();
|
|
std::thread::spawn(move || {
|
|
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
|
buf.lock().push(format!("[{lbl}] {line}"));
|
|
}
|
|
});
|
|
}
|
|
|
|
if service == Service::Lsx {
|
|
let address = LSX_ADDR;
|
|
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(error);
|
|
}
|
|
log.lock()
|
|
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
|
|
}
|
|
|
|
Ok(child)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn lsx_runs_its_own_binary_with_no_arguments() {
|
|
let parts = command_parts(Service::Lsx);
|
|
assert_eq!(
|
|
Path::new(&parts.program).file_name().unwrap(),
|
|
"openfut-lsx"
|
|
);
|
|
assert!(parts.args.is_empty(), "{:?}", parts.args);
|
|
}
|
|
|
|
#[test]
|
|
fn autopatch_runs_its_own_binary_with_launcher_ownership() {
|
|
let parts = command_parts(Service::Autopatch);
|
|
assert_eq!(
|
|
Path::new(&parts.program).file_name().unwrap(),
|
|
"openfut-autopatch"
|
|
);
|
|
// The launcher pid is how autopatch learns to exit with its owner.
|
|
assert_eq!(
|
|
parts.args,
|
|
vec!["--launcher-pid", &std::process::id().to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_companion_binary_is_looked_up_by_file_name_not_a_suffix_match() {
|
|
// Guards the foreign-process scan: an argv entry that merely ends with the
|
|
// binary name (a log path, say) must not be mistaken for the service.
|
|
assert_eq!(Service::Lsx.binary(), "openfut-lsx");
|
|
assert_eq!(Service::Autopatch.binary(), "openfut-autopatch");
|
|
assert_eq!(
|
|
Path::new("/var/log/my-openfut-lsx").file_name().unwrap(),
|
|
"my-openfut-lsx"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_work_is_dispatched_without_blocking_the_caller() {
|
|
use std::time::{Duration, Instant};
|
|
|
|
let started = Instant::now();
|
|
let done = dispatch_stop_work(|| {
|
|
std::thread::sleep(Duration::from_millis(250));
|
|
Ok(())
|
|
});
|
|
|
|
assert!(started.elapsed() < Duration::from_millis(100));
|
|
assert!(done.try_recv().is_err());
|
|
assert!(done.recv_timeout(Duration::from_secs(1)).unwrap().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn readiness_rejects_an_lsx_child_that_exits_before_binding() {
|
|
let mut child = Command::new("sh")
|
|
.args(["-c", "exit 7"])
|
|
.spawn()
|
|
.expect("spawn short-lived child");
|
|
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
|
|
let error = wait_for_listener_ready(&mut child, address, Duration::from_secs(1))
|
|
.expect_err("exited child must not be reported ready");
|
|
assert!(error.to_string().contains("exited before becoming ready"));
|
|
}
|
|
|
|
fn supervisor() -> ServiceSupervisor {
|
|
ServiceSupervisor::new(Arc::new(Mutex::new(LogBuffer::new())))
|
|
}
|
|
|
|
#[test]
|
|
fn a_service_this_launcher_never_started_is_never_reported_as_ours() {
|
|
// The old model only knew about children it spawned, so it could not tell
|
|
// "stopped" from "running, but not mine". Note this box may genuinely have
|
|
// a foreign responder running — that is a real observation, and the
|
|
// invariant is about ownership, not about it being absent.
|
|
let mut sup = supervisor();
|
|
let runtime = sup.observe(Service::Autopatch);
|
|
assert!(
|
|
!runtime.started_by_launcher,
|
|
"nothing was spawned here, so nothing may claim launcher ownership"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_launcher_owned_child_is_observed_as_ours_and_reaped_when_it_dies() {
|
|
let mut sup = supervisor();
|
|
let child = Command::new("sh")
|
|
.args(["-c", "sleep 30"])
|
|
.spawn()
|
|
.expect("spawn long-lived child");
|
|
let pid = child.id();
|
|
sup.autopatch = ManagedService::from_child(child);
|
|
|
|
let runtime = sup.observe(Service::Autopatch);
|
|
assert!(runtime.running);
|
|
assert!(runtime.started_by_launcher, "we spawned it");
|
|
assert_eq!(runtime.pid, Some(pid));
|
|
|
|
// Stopping is allowed precisely because it is ours.
|
|
sup.stop(Service::Autopatch).expect("ours to stop");
|
|
}
|
|
|
|
#[test]
|
|
fn stopping_a_foreign_service_is_refused_rather_than_killing_it() {
|
|
// A service someone started by hand for a debugging session must survive a
|
|
// launch/exit cycle, and the refusal has to say where to stop it. Asserted
|
|
// on the pure rule so it holds regardless of what this machine is running.
|
|
let foreign = ServiceRuntime {
|
|
running: true,
|
|
started_by_launcher: false,
|
|
pid: Some(4242),
|
|
detail: None,
|
|
};
|
|
let error = stop_permitted(&foreign, "autopatch").unwrap_err();
|
|
assert!(error.contains("started outside this launcher"), "{error}");
|
|
assert!(error.contains("4242"), "{error}");
|
|
|
|
let ours = ServiceRuntime {
|
|
running: true,
|
|
started_by_launcher: true,
|
|
pid: Some(99),
|
|
detail: None,
|
|
};
|
|
assert!(stop_permitted(&ours, "autopatch").is_ok());
|
|
// Stopping something that is not running is a harmless no-op.
|
|
assert!(stop_permitted(&ServiceRuntime::default(), "autopatch").is_ok());
|
|
|
|
assert!(
|
|
crate::launch::services_to_stop(
|
|
crate::launch::CleanupPolicy {
|
|
stop_launcher_started_services: true,
|
|
},
|
|
&[(Service::Autopatch, foreign)],
|
|
)
|
|
.is_empty(),
|
|
"a foreign service is never in the stop list"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn foreign_pid_ignores_the_launcher_process_itself() {
|
|
// The scan matches on the responder script name; this process is not one,
|
|
// and must never be reported as a service.
|
|
assert_ne!(foreign_pid(Service::Lsx, None), Some(std::process::id()));
|
|
assert_ne!(
|
|
foreign_pid(Service::Autopatch, None),
|
|
Some(std::process::id())
|
|
);
|
|
}
|
|
}
|