launcher: one Launch button, driven by an explicit launch state machine
The launcher used to make the user perform OpenFUT's internal launch order by
hand — Start LSX, Start autopatch, Run pre-launch checks, "Arm client", then a
button called *Start Services & Launch Game*. Those are implementation details
of how FIFA 17 is persuaded to talk to OpenFUT, and getting the order wrong
produced failures that surfaced much later as "the game crashed": autopatch
started before ptrace_scope is 0 silently patches nothing at all.
The normal flow is now: open the launcher, read one status card, press
**Launch FIFA 17**.
New `launch` module holds the sequence as a state machine (Phase: Idle,
Checking, PreparingClient, StartingServices, Validating, Launching, Running,
Failed) and runs it on a worker thread, so the UI thread never blocks on a
socket, a Polkit prompt or a process spawn. The UI renders that state; it does
not coordinate services.
Every step asks what is already true before acting:
- a healthy service is reused, never restarted;
- client preparation is skipped when the checks it would repair already pass,
which also avoids a pointless password prompt;
- the hook config is reconciled from the current settings.
It stops at the first failed step and never starts FIFA into a client it knows
is broken. Preparation deliberately runs BEFORE autopatch, against the order in
the brief, because autopatch cannot write FIFA's memory until arming has set
ptrace_scope and would otherwise "succeed" while doing nothing.
Ownership is now tracked, which the old model could not express: it only knew
about children it had spawned, so a service started by hand for a debugging
session read as "stopped" and starting it again just collided on the port.
`ServiceSupervisor` observes our own child first, then scans /proc for a foreign
instance, and reports `ServiceRuntime { running, started_by_launcher, pid,
detail }`. `stop_permitted` refuses to kill anything the launcher did not start,
under any cleanup policy. `CleanupPolicy` states the shipped behaviour — leave
launcher-started services running for the next launch — instead of leaving it to
chance, and the FIFA-exit path goes through it.
Readiness comes from observation, never from a button press: LSX is ready only
when the port FIFA dials is actually held, and "we have not looked" renders as
"Not checked yet", never as green.
Manual controls all survive under **Advanced / Diagnostics** — per-service
start/stop/restart with PIDs and ownership, "Prepare client" (the old "Arm
client", renamed; internals still say arm), "Run pre-launch checks", "View
logs", and a new "Launch game only" escape hatch for debugging a launch the
sequence refuses.
Tests: 73 pass (15 new). Sequencing and ownership are unit-tested through a
`LaunchOps` fake, so "don't launch after a failed step", "don't restart healthy
services" and "don't kill what we didn't start" hold without a FIFA install, a
Polkit agent or root.
Exercised live under Xvfb: the card shows four observed rows and one button; a
launch stopped at LSX with "127.0.0.1:4216 is held by an unrelated process",
listed every step's verdict, and did NOT start the game; Advanced showed a real
pre-existing autopatch as "Running (foreign) · pid 382382 · started outside this
launcher" with Stop/Restart disabled.
This commit is contained in:
+325
-2
@@ -29,6 +29,10 @@ use crate::fifa17_capability::{
|
||||
};
|
||||
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,
|
||||
@@ -36,7 +40,7 @@ struct CommandParts {
|
||||
}
|
||||
|
||||
/// Which companion service. The `str` values are used in log prefixes.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Service {
|
||||
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
||||
Lsx,
|
||||
@@ -179,6 +183,11 @@ impl ManagedService {
|
||||
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() {
|
||||
@@ -219,6 +228,235 @@ pub struct CapabilityWiring {
|
||||
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 responder script 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.
|
||||
pub fn foreign_pid(service: Service, ours: Option<u32>) -> Option<u32> {
|
||||
let script = service.script();
|
||||
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| String::from_utf8_lossy(arg).ends_with(script))
|
||||
{
|
||||
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.python,
|
||||
&spec.tools_dir,
|
||||
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 python: String,
|
||||
pub tools_dir: String,
|
||||
pub persona_id: u64,
|
||||
pub persona_name: String,
|
||||
pub capability: Option<CapabilityWiring>,
|
||||
}
|
||||
|
||||
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
|
||||
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
|
||||
/// Returns an error (without spawning) if the tools dir or script is missing.
|
||||
@@ -350,7 +588,7 @@ pub fn spawn(
|
||||
}
|
||||
|
||||
if service == Service::Lsx {
|
||||
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
|
||||
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();
|
||||
@@ -418,4 +656,89 @@ mod tests {
|
||||
.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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user