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:
+639
-419
File diff suppressed because it is too large
Load Diff
+21
-5
@@ -42,8 +42,13 @@ fn say(log: &Log, msg: impl Into<String>) {
|
||||
/// Prepare the prefix, satisfy the licence precondition, and start the game.
|
||||
///
|
||||
/// Returns once the game process has been spawned; its output continues to
|
||||
/// stream into `log` on background threads.
|
||||
pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
/// stream into `log` on background threads. `on_exit` fires when the process
|
||||
/// ends, which is how the launch state machine leaves its Running state.
|
||||
pub fn launch(
|
||||
profile: &GameProfile,
|
||||
log: &Log,
|
||||
on_exit: impl FnOnce() + Send + 'static,
|
||||
) -> anyhow::Result<()> {
|
||||
profile.validate().map_err(anyhow::Error::msg)?;
|
||||
|
||||
let game_dir = PathBuf::from(&profile.game_dir);
|
||||
@@ -79,7 +84,12 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
|
||||
stream(child, log.clone(), "[launcher] game process exited.");
|
||||
stream(
|
||||
child,
|
||||
log.clone(),
|
||||
"[launcher] game process exited.",
|
||||
on_exit,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -222,7 +232,12 @@ fn non_empty_file(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Pump a child's stdout and stderr into the log buffer and reap it.
|
||||
pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
||||
pub fn stream(
|
||||
mut child: Child,
|
||||
log: Log,
|
||||
exit_msg: &'static str,
|
||||
on_exit: impl FnOnce() + Send + 'static,
|
||||
) {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
std::thread::spawn(move || {
|
||||
@@ -242,6 +257,7 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
log.lock().push(exit_msg.to_string());
|
||||
on_exit();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -444,7 +460,7 @@ mod tests {
|
||||
game_dir: "/definitely/not/here".into(),
|
||||
..GameProfile::default()
|
||||
};
|
||||
let err = launch(&profile, &log()).unwrap_err().to_string();
|
||||
let err = launch(&profile, &log(), || {}).unwrap_err().to_string();
|
||||
assert!(err.contains("game_dir does not exist"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
+942
@@ -0,0 +1,942 @@
|
||||
//! The launch sequence, as an explicit state machine.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! 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
|
||||
//! press a button called *Start Services & Launch Game*. Every one of those is an
|
||||
//! implementation detail 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` was 0 silently does
|
||||
//! nothing at all.
|
||||
//!
|
||||
//! So the sequence lives here, once, and the UI renders it. One button.
|
||||
//!
|
||||
//! # Ordering, and where it deviates from the obvious
|
||||
//!
|
||||
//! Client preparation (`arm`) runs BEFORE autopatch, not after: autopatch writes
|
||||
//! `/proc/<FIFA17.exe>/mem`, which Yama forbids until arming sets
|
||||
//! `kernel.yama.ptrace_scope=0`. Starting autopatch first would "succeed" and
|
||||
//! then quietly fail to patch anything.
|
||||
//!
|
||||
//! # Idempotence
|
||||
//!
|
||||
//! 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 an unnecessary Polkit prompt.
|
||||
//!
|
||||
//! # Testability
|
||||
//!
|
||||
//! The effects — spawning services, elevating for arming, writing the hook
|
||||
//! config, starting the game — sit behind [`LaunchOps`]. [`run_sequence`] is
|
||||
//! therefore a pure decision procedure over observed state, and the sequencing
|
||||
//! rules that matter (don't launch after a failed step, don't restart healthy
|
||||
//! services, don't kill what we didn't start) are unit-testable without a FIFA
|
||||
//! install, a Polkit agent, or root.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::LauncherConfig;
|
||||
use crate::fifa17_capability::Fifa17ClientCapabilities;
|
||||
use crate::local_services::{
|
||||
CapabilityWiring, Ensured, Service, ServiceRuntime, ServiceSupervisor, SpawnSpec,
|
||||
};
|
||||
use crate::logs::LogBuffer;
|
||||
use crate::preflight::{self, Check, State};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
/// Where the launch sequence is. Rendered directly by the UI; the UI never
|
||||
/// coordinates services itself.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Phase {
|
||||
/// Nothing in flight. Readiness still comes from observed state, not from
|
||||
/// having been here.
|
||||
#[default]
|
||||
Idle,
|
||||
/// Looking at the world: checks + service + hook state.
|
||||
Checking,
|
||||
/// Elevated client preparation in flight (this is what shows a password
|
||||
/// prompt).
|
||||
PreparingClient,
|
||||
StartingServices,
|
||||
/// Re-checking after repair, before committing to a launch.
|
||||
Validating,
|
||||
Launching,
|
||||
/// FIFA is up. Left when the process exits.
|
||||
Running,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
/// Whether a launch is under way, i.e. the primary button must not start a
|
||||
/// second one.
|
||||
pub fn busy(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Phase::Checking
|
||||
| Phase::PreparingClient
|
||||
| Phase::StartingServices
|
||||
| Phase::Validating
|
||||
| Phase::Launching
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of the sequence, in execution order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Step {
|
||||
Server,
|
||||
ClientFiles,
|
||||
ClientPreparation,
|
||||
Lsx,
|
||||
Autopatch,
|
||||
FinalChecks,
|
||||
Game,
|
||||
}
|
||||
|
||||
impl Step {
|
||||
/// User-facing name. Deliberately not the internal vocabulary: "arm" is
|
||||
/// implementation terminology and never appears in the normal flow.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Step::Server => "OpenFUT server",
|
||||
Step::ClientFiles => "Client files",
|
||||
Step::ClientPreparation => "Client preparation",
|
||||
Step::Lsx => "LSX",
|
||||
Step::Autopatch => "Autopatch",
|
||||
Step::FinalChecks => "Final checks",
|
||||
Step::Game => "FIFA 17",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a step ended. `Skipped` is a success that did nothing — the state it
|
||||
/// would have produced was already true.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Done(String),
|
||||
Skipped(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
pub fn ok(&self) -> bool {
|
||||
!matches!(self, Outcome::Failed(_))
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &str {
|
||||
match self {
|
||||
Outcome::Done(d) | Outcome::Skipped(d) | Outcome::Failed(d) => d,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the UI needs to render the launch surface.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LaunchState {
|
||||
pub phase: Phase,
|
||||
/// Steps attempted by the most recent run, in order.
|
||||
pub steps: Vec<(Step, Outcome)>,
|
||||
/// One-line reason the run failed, for the top of the failure card. The
|
||||
/// per-step detail carries the specifics.
|
||||
pub failure: Option<String>,
|
||||
/// The most recent preflight results and when they were taken. Cached
|
||||
/// because the checks open sockets with timeouts and cannot run per frame.
|
||||
pub checks: Option<Vec<Check>>,
|
||||
pub checks_age: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl LaunchState {
|
||||
fn begin(&mut self, phase: Phase) {
|
||||
self.phase = phase;
|
||||
self.steps.clear();
|
||||
self.failure = None;
|
||||
}
|
||||
|
||||
fn record(&mut self, step: Step, outcome: Outcome) {
|
||||
if let Outcome::Failed(reason) = &outcome {
|
||||
self.failure = Some(format!("{}: {reason}", step.label()));
|
||||
}
|
||||
self.steps.push((step, outcome));
|
||||
}
|
||||
}
|
||||
|
||||
/// The effects the sequence performs. Implemented for real by [`RealOps`] and
|
||||
/// substituted in tests.
|
||||
pub trait LaunchOps {
|
||||
/// Confirm the configured OpenFUT server is answering AND select the account
|
||||
/// for this session. The server is remote by design, so this is a network
|
||||
/// fact, never "is something local up". Returns a user-facing summary.
|
||||
fn connect_server(&mut self) -> Result<String, String>;
|
||||
/// Version.dll + a readable openfut.cfg. `Err` is a hard stop: without them
|
||||
/// FIFA talks to EA, not OpenFUT.
|
||||
fn ensure_client_files(&mut self) -> Result<String, String>;
|
||||
/// Which of the arming-repairable checks are currently failing.
|
||||
fn run_checks(&mut self) -> Vec<Check>;
|
||||
/// Elevated client preparation (`arm`). Returns what it changed.
|
||||
fn prepare_client(&mut self) -> Result<Vec<String>, String>;
|
||||
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String>;
|
||||
fn start_game(&mut self) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// Checks that client preparation is able to repair. A failure in any of these
|
||||
/// means "prepare the client", not "give up".
|
||||
fn preparation_repairs(check: &Check) -> bool {
|
||||
const REPAIRABLE: [&str; 3] = [
|
||||
"ptrace_scope (autopatch)",
|
||||
"EA redirector IP is redirected",
|
||||
"EA hostnames point at OpenFUT",
|
||||
];
|
||||
REPAIRABLE.contains(&check.name.as_str())
|
||||
}
|
||||
|
||||
/// Run the whole sequence, publishing progress into `state` as it goes.
|
||||
///
|
||||
/// Returns whether FIFA was started. Stops at the first failed step: launching
|
||||
/// into a known-broken client produces a session that fails minutes later with
|
||||
/// no message naming the cause, which is precisely the failure mode this
|
||||
/// launcher exists to prevent.
|
||||
pub fn run_sequence(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) -> bool {
|
||||
macro_rules! step {
|
||||
($phase:expr, $step:expr, $body:expr) => {{
|
||||
state.lock().phase = $phase;
|
||||
let outcome: Outcome = $body;
|
||||
let ok = outcome.ok();
|
||||
state.lock().record($step, outcome);
|
||||
if !ok {
|
||||
state.lock().phase = Phase::Failed;
|
||||
return false;
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
state.lock().begin(Phase::Checking);
|
||||
|
||||
// ── The server, which is remote and not ours to start ────────────────────
|
||||
step!(Phase::Checking, Step::Server, {
|
||||
match ops.connect_server() {
|
||||
Ok(detail) => Outcome::Done(detail),
|
||||
Err(e) => Outcome::Failed(e),
|
||||
}
|
||||
});
|
||||
|
||||
// ── The hook the game loads, reconciled with the current settings ────────
|
||||
step!(Phase::Checking, Step::ClientFiles, {
|
||||
match ops.ensure_client_files() {
|
||||
Ok(detail) => Outcome::Done(detail),
|
||||
Err(e) => Outcome::Failed(e),
|
||||
}
|
||||
});
|
||||
|
||||
// ── Client preparation, only if something it repairs is broken ───────────
|
||||
let checks = ops.run_checks();
|
||||
let broken: Vec<String> = checks
|
||||
.iter()
|
||||
.filter(|c| c.state == State::Fail && preparation_repairs(c))
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
{
|
||||
let mut guard = state.lock();
|
||||
guard.checks = Some(checks);
|
||||
guard.checks_age = Some(std::time::Instant::now());
|
||||
}
|
||||
step!(Phase::PreparingClient, Step::ClientPreparation, {
|
||||
if broken.is_empty() {
|
||||
Outcome::Skipped("already prepared".into())
|
||||
} else {
|
||||
match ops.prepare_client() {
|
||||
Ok(changes) => Outcome::Done(format!("{} change(s) applied", changes.len())),
|
||||
Err(e) => Outcome::Failed(e),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Companion services, in dependency order ─────────────────────────────
|
||||
for (service, step) in [
|
||||
(Service::Lsx, Step::Lsx),
|
||||
(Service::Autopatch, Step::Autopatch),
|
||||
] {
|
||||
step!(Phase::StartingServices, step, {
|
||||
match ops.ensure_service(service) {
|
||||
Ok(Ensured::Reused) => Outcome::Skipped("already running".into()),
|
||||
Ok(Ensured::Started) => Outcome::Done("started".into()),
|
||||
Err(e) => Outcome::Failed(e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Validate what the repairs were supposed to fix ──────────────────────
|
||||
step!(Phase::Validating, Step::FinalChecks, {
|
||||
let checks = ops.run_checks();
|
||||
let failed: Vec<String> = checks
|
||||
.iter()
|
||||
.filter(|c| c.state == State::Fail)
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
{
|
||||
let mut guard = state.lock();
|
||||
guard.checks = Some(checks);
|
||||
guard.checks_age = Some(std::time::Instant::now());
|
||||
}
|
||||
if failed.is_empty() {
|
||||
Outcome::Done("all checks pass".into())
|
||||
} else {
|
||||
Outcome::Failed(format!("still failing: {}", failed.join(", ")))
|
||||
}
|
||||
});
|
||||
|
||||
step!(Phase::Launching, Step::Game, {
|
||||
match ops.start_game() {
|
||||
Ok(()) => Outcome::Done("started".into()),
|
||||
Err(e) => Outcome::Failed(e),
|
||||
}
|
||||
});
|
||||
|
||||
state.lock().phase = Phase::Running;
|
||||
true
|
||||
}
|
||||
|
||||
/// Observe the world without changing it, for the status rows on open and after
|
||||
/// a settings change. Shares [`run_sequence`]'s notion of what "ready" means so
|
||||
/// the two cannot drift apart.
|
||||
pub fn refresh_checks(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) {
|
||||
state.lock().phase = Phase::Checking;
|
||||
let checks = ops.run_checks();
|
||||
let mut guard = state.lock();
|
||||
guard.checks = Some(checks);
|
||||
guard.checks_age = Some(std::time::Instant::now());
|
||||
guard.phase = Phase::Idle;
|
||||
}
|
||||
|
||||
/// What happens to launcher-started services when FIFA exits.
|
||||
///
|
||||
/// Exists so the answer is a stated policy rather than an oversight. The shipped
|
||||
/// value stops nothing:
|
||||
///
|
||||
/// * The companion services are reusable across launches — LSX has to be holding
|
||||
/// :4216 before FIFA dials it, and the next launch would only start them again.
|
||||
/// * A service the launcher did NOT start is never in the stop list under any
|
||||
/// value of this policy.
|
||||
///
|
||||
/// Client preparation is deliberately absent, and is never reverted: it is host
|
||||
/// state (`ptrace_scope`, a DNAT, `/etc/hosts`) that `client_arm.sh` also leaves
|
||||
/// set and that every subsequent launch needs. A flag for it would be a flag
|
||||
/// nothing honours.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct CleanupPolicy {
|
||||
pub stop_launcher_started_services: bool,
|
||||
}
|
||||
|
||||
/// Which services cleanup is allowed to stop after `FIFA` exits: only ones this
|
||||
/// launcher started, and only if the policy says so.
|
||||
pub fn services_to_stop(
|
||||
policy: CleanupPolicy,
|
||||
runtimes: &[(Service, ServiceRuntime)],
|
||||
) -> Vec<Service> {
|
||||
if !policy.stop_launcher_started_services {
|
||||
return Vec::new();
|
||||
}
|
||||
runtimes
|
||||
.iter()
|
||||
.filter(|(_, r)| r.running && r.started_by_launcher)
|
||||
.map(|(s, _)| *s)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Summary of one dependency for the main card.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Readiness {
|
||||
Ready,
|
||||
Busy,
|
||||
Attention,
|
||||
/// Never looked, or the answer is stale. Never rendered as Ready.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Client-integration readiness from the cached checks. `Unknown` until a run has
|
||||
/// actually happened: "we did not look" must not look like "we looked and it was
|
||||
/// fine".
|
||||
pub fn client_integration(state: &LaunchState) -> Readiness {
|
||||
if matches!(state.phase, Phase::PreparingClient) {
|
||||
return Readiness::Busy;
|
||||
}
|
||||
match &state.checks {
|
||||
None => Readiness::Unknown,
|
||||
Some(checks) => {
|
||||
let relevant: Vec<&Check> = checks.iter().filter(|c| preparation_repairs(c)).collect();
|
||||
if relevant.iter().any(|c| c.state == State::Fail) {
|
||||
Readiness::Attention
|
||||
} else if relevant.iter().all(|c| c.state == State::Skipped) {
|
||||
// Nothing configured to check, so nothing was verified.
|
||||
Readiness::Unknown
|
||||
} else {
|
||||
Readiness::Ready
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overall readiness for the card's headline pill. Anything short of every
|
||||
/// dependency being observed-good is not Ready.
|
||||
pub fn overall(
|
||||
phase: Phase,
|
||||
server: Readiness,
|
||||
integration: Readiness,
|
||||
services: Readiness,
|
||||
hook: Readiness,
|
||||
) -> Readiness {
|
||||
if phase == Phase::Running {
|
||||
return Readiness::Ready;
|
||||
}
|
||||
if phase.busy() {
|
||||
return Readiness::Busy;
|
||||
}
|
||||
let parts = [server, integration, services, hook];
|
||||
if parts.contains(&Readiness::Attention) {
|
||||
Readiness::Attention
|
||||
} else if parts.contains(&Readiness::Unknown) {
|
||||
Readiness::Unknown
|
||||
} else {
|
||||
Readiness::Ready
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LaunchOps`] against the actual machine.
|
||||
///
|
||||
/// Holds a snapshot of the config: a launch must not change its mind halfway
|
||||
/// through because the user edited a field while it ran.
|
||||
pub struct RealOps {
|
||||
config: LauncherConfig,
|
||||
services: Arc<Mutex<ServiceSupervisor>>,
|
||||
logs: Arc<Mutex<LogBuffer>>,
|
||||
caps: Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||
state: Arc<Mutex<LaunchState>>,
|
||||
}
|
||||
|
||||
impl RealOps {
|
||||
fn say(&self, message: impl Into<String>) {
|
||||
self.logs.lock().push(message.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl LaunchOps for RealOps {
|
||||
fn connect_server(&mut self) -> Result<String, String> {
|
||||
self.config.validate_server()?;
|
||||
if preflight::backend_reachable(&self.config).state == State::Fail {
|
||||
return Err(format!(
|
||||
"{} is not answering — is the OpenFUT server running?",
|
||||
self.config.openfut_server_host
|
||||
));
|
||||
}
|
||||
// Selecting the account is part of connecting: LSX and FIFA both
|
||||
// authenticate as this persona, and a launch with the wrong one produces
|
||||
// a session that looks fine and belongs to nobody.
|
||||
let account = crate::account_sync::sync(&self.config)?;
|
||||
self.say(format!(
|
||||
"[launcher] account synchronized: {}/{} FUT-coins={} unopened-packs={}",
|
||||
account.persona_id, account.persona_name, account.coins, account.unopened_packs
|
||||
));
|
||||
Ok(format!(
|
||||
"{} · {}",
|
||||
self.config.openfut_server_host, account.persona_name
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_client_files(&mut self) -> Result<String, String> {
|
||||
let game_dir = std::path::PathBuf::from(&self.config.fifa_game_dir);
|
||||
if !crate::setup::hook_dll_deployed(&game_dir) {
|
||||
return Err("network hook is not deployed — use Setup to deploy it".into());
|
||||
}
|
||||
// The file the game reads is reconciled here, and only here: this is the
|
||||
// one moment it is guaranteed to agree with the settings on screen.
|
||||
let contents = self.config.hook_cfg_contents()?;
|
||||
crate::setup::update_hook_config(&game_dir, &contents).map_err(|e| {
|
||||
format!(
|
||||
"cannot write {} in {}: {e}",
|
||||
crate::setup::HOOK_CFG_FILE,
|
||||
self.config.fifa_game_dir
|
||||
)
|
||||
})?;
|
||||
Ok(format!(
|
||||
"hook → {}:{}",
|
||||
self.config.openfut_server_host, self.config.openfut_https_port
|
||||
))
|
||||
}
|
||||
|
||||
fn run_checks(&mut self) -> Vec<Check> {
|
||||
preflight::run(&self.config)
|
||||
}
|
||||
|
||||
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
|
||||
match crate::arm::arm(&self.config) {
|
||||
Ok(changes) => {
|
||||
for change in &changes {
|
||||
self.say(format!("[launcher] prepared: {change}"));
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
|
||||
let spec = SpawnSpec {
|
||||
python: self.config.fifa17_python.clone(),
|
||||
tools_dir: self.config.fifa17_tools_dir.clone(),
|
||||
persona_id: self.config.fut_persona_id,
|
||||
persona_name: self.config.fut_persona_name.clone(),
|
||||
// Only autopatch advertises the verified resolver guard, so only it
|
||||
// receives the shared capability sink.
|
||||
capability: match service {
|
||||
Service::Autopatch => Some(CapabilityWiring {
|
||||
server_host: self.config.openfut_server_host.clone(),
|
||||
account_sync_port: self.config.openfut_account_sync_port,
|
||||
sink: Arc::clone(&self.caps),
|
||||
}),
|
||||
Service::Lsx => None,
|
||||
},
|
||||
};
|
||||
self.services.lock().ensure_running(service, spec)
|
||||
}
|
||||
|
||||
fn start_game(&mut self) -> Result<(), String> {
|
||||
// A new FIFA process starts with UNKNOWN capability: never inherit the
|
||||
// previous launch's. The autopatch stdout reader repopulates it.
|
||||
*self.caps.lock() = Default::default();
|
||||
|
||||
let state = Arc::clone(&self.state);
|
||||
let logs = Arc::clone(&self.logs);
|
||||
let services = Arc::clone(&self.services);
|
||||
let on_exit = move || {
|
||||
// Cleanup goes through the policy rather than through habit, so the
|
||||
// list can never include a service this launcher did not start.
|
||||
let runtimes: Vec<_> = {
|
||||
let mut supervisor = services.lock();
|
||||
[Service::Lsx, Service::Autopatch]
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let runtime = supervisor.observe(s);
|
||||
(s, runtime)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for service in services_to_stop(CleanupPolicy::default(), &runtimes) {
|
||||
if let Err(e) = services.lock().stop(service) {
|
||||
logs.lock().push(format!("[launcher] cleanup: {e}"));
|
||||
}
|
||||
}
|
||||
state.lock().phase = Phase::Idle;
|
||||
logs.lock()
|
||||
.push("[launcher] FIFA exited; launcher back to Ready.".to_string());
|
||||
};
|
||||
|
||||
// Prefer the native profile; fall back to the user's shell command so an
|
||||
// existing working setup keeps working after an upgrade.
|
||||
if self.config.game_profile.configured() {
|
||||
crate::game_launch::launch(&self.config.game_profile, &self.logs, on_exit)
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
crate::setup::launch_game(
|
||||
&self.config.game_launch_command,
|
||||
&self.config.game_launch_workdir,
|
||||
Arc::clone(&self.logs),
|
||||
on_exit,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives [`run_sequence`] on a worker thread. The UI thread never blocks on a
|
||||
/// socket, a Polkit prompt or a process spawn.
|
||||
pub struct Controller {
|
||||
pub state: Arc<Mutex<LaunchState>>,
|
||||
pub services: Arc<Mutex<ServiceSupervisor>>,
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new(logs: Arc<Mutex<LogBuffer>>) -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(LaunchState::default())),
|
||||
services: Arc::new(Mutex::new(ServiceSupervisor::new(logs))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> LaunchState {
|
||||
self.state.lock().clone()
|
||||
}
|
||||
|
||||
fn ops(
|
||||
&self,
|
||||
config: &LauncherConfig,
|
||||
logs: &Arc<Mutex<LogBuffer>>,
|
||||
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||
) -> RealOps {
|
||||
RealOps {
|
||||
config: config.clone(),
|
||||
services: Arc::clone(&self.services),
|
||||
logs: Arc::clone(logs),
|
||||
caps: Arc::clone(caps),
|
||||
state: Arc::clone(&self.state),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the full sequence. Ignored while one is already in flight or the
|
||||
/// game is up — the button reflects that state rather than queueing work.
|
||||
pub fn launch(
|
||||
&self,
|
||||
config: &LauncherConfig,
|
||||
logs: &Arc<Mutex<LogBuffer>>,
|
||||
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||
) {
|
||||
{
|
||||
let phase = self.state.lock().phase;
|
||||
if phase.busy() || phase == Phase::Running {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut ops = self.ops(config, logs, caps);
|
||||
let state = Arc::clone(&self.state);
|
||||
std::thread::spawn(move || {
|
||||
run_sequence(&mut ops, &state);
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-observe without changing anything, for startup and after a settings
|
||||
/// change. Skipped while a launch owns the state.
|
||||
pub fn refresh(
|
||||
&self,
|
||||
config: &LauncherConfig,
|
||||
logs: &Arc<Mutex<LogBuffer>>,
|
||||
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||
) {
|
||||
{
|
||||
let phase = self.state.lock().phase;
|
||||
if phase.busy() || phase == Phase::Running {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut ops = self.ops(config, logs, caps);
|
||||
let state = Arc::clone(&self.state);
|
||||
std::thread::spawn(move || {
|
||||
refresh_checks(&mut ops, &state);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Records what the sequence asked for, and answers however the test wants.
|
||||
#[derive(Default)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
struct FakeOps {
|
||||
server_up: bool,
|
||||
client_files: Option<Result<String, String>>,
|
||||
checks: Vec<Check>,
|
||||
checks_after_prepare: Option<Vec<Check>>,
|
||||
prepare_result: Option<Result<Vec<String>, String>>,
|
||||
service_result: Vec<(Service, Result<Ensured, String>)>,
|
||||
game_result: Option<Result<(), String>>,
|
||||
// Observed calls
|
||||
prepared: usize,
|
||||
started: Vec<Service>,
|
||||
game_started: usize,
|
||||
check_runs: usize,
|
||||
}
|
||||
|
||||
fn check(name: &str, state: State) -> Check {
|
||||
Check {
|
||||
name: name.into(),
|
||||
state,
|
||||
detail: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ready_ops() -> FakeOps {
|
||||
FakeOps {
|
||||
server_up: true,
|
||||
client_files: Some(Ok("deployed".into())),
|
||||
checks: vec![
|
||||
check("ptrace_scope (autopatch)", State::Pass),
|
||||
check("EA redirector IP is redirected", State::Pass),
|
||||
check("EA hostnames point at OpenFUT", State::Pass),
|
||||
],
|
||||
prepare_result: Some(Ok(vec!["one".into()])),
|
||||
game_result: Some(Ok(())),
|
||||
..FakeOps::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl LaunchOps for FakeOps {
|
||||
fn connect_server(&mut self) -> Result<String, String> {
|
||||
if self.server_up {
|
||||
Ok("connected".into())
|
||||
} else {
|
||||
Err("not reachable — is the OpenFUT server running?".into())
|
||||
}
|
||||
}
|
||||
fn ensure_client_files(&mut self) -> Result<String, String> {
|
||||
self.client_files
|
||||
.clone()
|
||||
.unwrap_or_else(|| Err("no client-files result configured".into()))
|
||||
}
|
||||
fn run_checks(&mut self) -> Vec<Check> {
|
||||
self.check_runs += 1;
|
||||
match (&self.checks_after_prepare, self.prepared) {
|
||||
(Some(after), n) if n > 0 => after.clone(),
|
||||
_ => self.checks.clone(),
|
||||
}
|
||||
}
|
||||
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
|
||||
self.prepared += 1;
|
||||
self.prepare_result
|
||||
.clone()
|
||||
.unwrap_or_else(|| Err("no prepare configured".into()))
|
||||
}
|
||||
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
|
||||
self.started.push(service);
|
||||
self.service_result
|
||||
.iter()
|
||||
.find(|(s, _)| *s == service)
|
||||
.map(|(_, r)| r.clone())
|
||||
.unwrap_or(Ok(Ensured::Started))
|
||||
}
|
||||
fn start_game(&mut self) -> Result<(), String> {
|
||||
self.game_started += 1;
|
||||
self.game_result
|
||||
.clone()
|
||||
.unwrap_or_else(|| Err("no game result configured".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn state() -> Arc<Mutex<LaunchState>> {
|
||||
Arc::new(Mutex::new(LaunchState::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cold_client_is_prepared_and_started_in_dependency_order() {
|
||||
let mut ops = FakeOps {
|
||||
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
|
||||
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Pass)]),
|
||||
..ready_ops()
|
||||
};
|
||||
let st = state();
|
||||
assert!(run_sequence(&mut ops, &st));
|
||||
assert_eq!(
|
||||
ops.prepared, 1,
|
||||
"a failing repairable check must be repaired"
|
||||
);
|
||||
// Preparation before autopatch: autopatch cannot write FIFA's memory
|
||||
// until arming has set ptrace_scope, and would silently no-op.
|
||||
assert_eq!(ops.started, vec![Service::Lsx, Service::Autopatch]);
|
||||
assert_eq!(ops.game_started, 1);
|
||||
assert_eq!(st.lock().phase, Phase::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_prepared_client_is_not_prepared_again() {
|
||||
let mut ops = ready_ops();
|
||||
let st = state();
|
||||
assert!(run_sequence(&mut ops, &st));
|
||||
assert_eq!(ops.prepared, 0, "no password prompt for work already done");
|
||||
let steps = &st.lock().steps;
|
||||
let prep = steps
|
||||
.iter()
|
||||
.find(|(s, _)| *s == Step::ClientPreparation)
|
||||
.expect("preparation step recorded")
|
||||
.1
|
||||
.clone();
|
||||
assert!(matches!(prep, Outcome::Skipped(_)), "{prep:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_services_are_reused_rather_than_restarted() {
|
||||
let mut ops = FakeOps {
|
||||
service_result: vec![
|
||||
(Service::Lsx, Ok(Ensured::Reused)),
|
||||
(Service::Autopatch, Ok(Ensured::Reused)),
|
||||
],
|
||||
..ready_ops()
|
||||
};
|
||||
let st = state();
|
||||
assert!(run_sequence(&mut ops, &st));
|
||||
for step in [Step::Lsx, Step::Autopatch] {
|
||||
let outcome = st
|
||||
.lock()
|
||||
.steps
|
||||
.iter()
|
||||
.find(|(s, _)| *s == step)
|
||||
.expect("service step recorded")
|
||||
.1
|
||||
.clone();
|
||||
assert!(
|
||||
matches!(outcome, Outcome::Skipped(_)),
|
||||
"{step:?} {outcome:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(ops.game_started, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_server_stops_the_launch_before_anything_is_touched() {
|
||||
let mut ops = FakeOps {
|
||||
server_up: false,
|
||||
..ready_ops()
|
||||
};
|
||||
let st = state();
|
||||
assert!(!run_sequence(&mut ops, &st));
|
||||
assert_eq!(ops.prepared, 0);
|
||||
assert!(ops.started.is_empty(), "nothing may be started");
|
||||
assert_eq!(ops.game_started, 0);
|
||||
assert_eq!(st.lock().phase, Phase::Failed);
|
||||
assert!(st.lock().failure.as_deref().unwrap().contains("server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_that_fails_to_start_stops_the_launch() {
|
||||
let mut ops = FakeOps {
|
||||
service_result: vec![(Service::Autopatch, Err("autopatch: boom".into()))],
|
||||
..ready_ops()
|
||||
};
|
||||
let st = state();
|
||||
assert!(!run_sequence(&mut ops, &st));
|
||||
assert_eq!(ops.game_started, 0, "FIFA must not start without autopatch");
|
||||
let failure = st.lock().failure.clone().unwrap();
|
||||
assert!(failure.contains("Autopatch"), "{failure}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_client_preparation_stops_the_launch() {
|
||||
let mut ops = FakeOps {
|
||||
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
|
||||
prepare_result: Some(Err("pkexec: dismissed".into())),
|
||||
..ready_ops()
|
||||
};
|
||||
let st = state();
|
||||
assert!(!run_sequence(&mut ops, &st));
|
||||
assert!(ops.started.is_empty());
|
||||
assert_eq!(ops.game_started, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_still_failing_after_repair_stops_the_launch() {
|
||||
// Preparation ran and claimed success, but the state it was supposed to
|
||||
// fix is still broken. Launching here is how a session dies later with
|
||||
// no message naming the cause.
|
||||
let mut ops = FakeOps {
|
||||
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
|
||||
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Fail)]),
|
||||
..ready_ops()
|
||||
};
|
||||
let st = state();
|
||||
assert!(!run_sequence(&mut ops, &st));
|
||||
assert_eq!(ops.game_started, 0);
|
||||
let failure = st.lock().failure.clone().unwrap();
|
||||
assert!(failure.contains("still failing"), "{failure}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_files_failure_stops_the_launch() {
|
||||
let mut ops = FakeOps {
|
||||
client_files: Some(Err("cannot write openfut.cfg".into())),
|
||||
..ready_ops()
|
||||
};
|
||||
let st = state();
|
||||
assert!(!run_sequence(&mut ops, &st));
|
||||
assert_eq!(ops.game_started, 0);
|
||||
assert!(ops.started.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_never_stops_a_service_the_launcher_did_not_start() {
|
||||
let foreign = ServiceRuntime {
|
||||
running: true,
|
||||
started_by_launcher: false,
|
||||
pid: Some(4242),
|
||||
detail: None,
|
||||
};
|
||||
let ours = ServiceRuntime {
|
||||
running: true,
|
||||
started_by_launcher: true,
|
||||
pid: Some(99),
|
||||
detail: None,
|
||||
};
|
||||
let runtimes = [(Service::Lsx, foreign), (Service::Autopatch, ours)];
|
||||
|
||||
// Even under the most aggressive policy, a foreign service is untouched.
|
||||
let aggressive = CleanupPolicy {
|
||||
stop_launcher_started_services: true,
|
||||
};
|
||||
assert_eq!(
|
||||
services_to_stop(aggressive, &runtimes),
|
||||
vec![Service::Autopatch]
|
||||
);
|
||||
|
||||
// And the shipped policy keeps both alive for the next launch.
|
||||
assert!(services_to_stop(CleanupPolicy::default(), &runtimes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_is_never_green_while_a_dependency_is_not() {
|
||||
assert_eq!(
|
||||
overall(
|
||||
Phase::Idle,
|
||||
Readiness::Ready,
|
||||
Readiness::Ready,
|
||||
Readiness::Attention,
|
||||
Readiness::Ready
|
||||
),
|
||||
Readiness::Attention
|
||||
);
|
||||
// Never checked is not the same as checked and fine.
|
||||
assert_eq!(
|
||||
overall(
|
||||
Phase::Idle,
|
||||
Readiness::Ready,
|
||||
Readiness::Unknown,
|
||||
Readiness::Ready,
|
||||
Readiness::Ready
|
||||
),
|
||||
Readiness::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
overall(
|
||||
Phase::Idle,
|
||||
Readiness::Ready,
|
||||
Readiness::Ready,
|
||||
Readiness::Ready,
|
||||
Readiness::Ready
|
||||
),
|
||||
Readiness::Ready
|
||||
);
|
||||
// A running game reports Ready even though a launch is not in flight.
|
||||
assert_eq!(
|
||||
overall(
|
||||
Phase::Running,
|
||||
Readiness::Unknown,
|
||||
Readiness::Unknown,
|
||||
Readiness::Unknown,
|
||||
Readiness::Unknown
|
||||
),
|
||||
Readiness::Ready
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_integration_is_unknown_until_checks_have_run() {
|
||||
let mut st = LaunchState::default();
|
||||
assert_eq!(client_integration(&st), Readiness::Unknown);
|
||||
|
||||
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Fail)]);
|
||||
assert_eq!(client_integration(&st), Readiness::Attention);
|
||||
|
||||
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Pass)]);
|
||||
assert_eq!(client_integration(&st), Readiness::Ready);
|
||||
|
||||
// Only skipped checks means nothing was actually verified.
|
||||
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Skipped)]);
|
||||
assert_eq!(client_integration(&st), Readiness::Unknown);
|
||||
}
|
||||
}
|
||||
+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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ mod config;
|
||||
mod fifa17_capability;
|
||||
mod game_launch;
|
||||
mod health;
|
||||
mod launch;
|
||||
mod local_services;
|
||||
mod logs;
|
||||
mod netcheck;
|
||||
|
||||
+1
-1
@@ -239,7 +239,7 @@ fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
||||
}
|
||||
|
||||
/// The server side of the same question: are the ports the game will use open?
|
||||
fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
||||
pub(crate) fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "OpenFUT server reachable";
|
||||
let host = cfg.openfut_server_host.trim();
|
||||
if host.is_empty() {
|
||||
|
||||
+5
-1
@@ -140,6 +140,7 @@ pub fn launch_game(
|
||||
command: &str,
|
||||
workdir: &str,
|
||||
log_buf: std::sync::Arc<parking_lot::Mutex<crate::logs::LogBuffer>>,
|
||||
on_exit: impl FnOnce() + Send + 'static,
|
||||
) -> anyhow::Result<()> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -178,12 +179,15 @@ pub fn launch_game(
|
||||
});
|
||||
}
|
||||
// Reap the child in the background so a finished game doesn't linger as a
|
||||
// zombie; we don't block the UI on it.
|
||||
// zombie; we don't block the UI on it. `on_exit` is how the launch state
|
||||
// machine learns the game is gone — without it the UI would sit on
|
||||
// "FIFA 17 Running" forever.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
log_buf
|
||||
.lock()
|
||||
.push("[launcher] game process exited.".to_string());
|
||||
on_exit();
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user