966e92b304
The prior Windows branch treated BOTH companions as in-process and started neither. That is wrong for LSX: FIFA dials the Origin/LSX emulator on 127.0.0.1:4216 and it must run locally on the client (the STEAMPUNKS stp-origin_emu.dll is the crack's activation emu, not OpenFUT's LSX). Only autopatch is genuinely in-process on Windows (its ProtoSSL cert patch is done by the version.dll hook), so skip just that one and spawn LSX through the normal path. Also resolve the companion as openfut-lsx.exe on Windows.
799 lines
29 KiB
Rust
799 lines
29 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},
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
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 base = service.binary();
|
|
// On Windows the built companion is `openfut-lsx.exe`; a bare name without the
|
|
// extension matches neither the sibling file nor CreateProcess resolution.
|
|
#[cfg(windows)]
|
|
let name = format!("{base}.exe");
|
|
#[cfg(unix)]
|
|
let name = base.to_string();
|
|
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> {
|
|
// On Windows the ProtoSSL cert-verify patch (autopatch's job on unix, via
|
|
// /proc/PID/mem) is performed in-process by the version.dll hook, so there
|
|
// is no autopatch process to run. LSX is different: the game dials it on
|
|
// 127.0.0.1:4216, so it MUST run locally here exactly as on unix.
|
|
#[cfg(windows)]
|
|
if service == Service::Autopatch {
|
|
self.log.lock().push(
|
|
"[launcher] autopatch runs in-process on Windows (version.dll hook) — nothing to start."
|
|
.to_string(),
|
|
);
|
|
return Ok(Ensured::Reused);
|
|
}
|
|
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.
|
|
#[cfg(unix)]
|
|
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())
|
|
);
|
|
}
|
|
}
|