Files
openfut-launcher/src/launch.rs
T
funman300 1cd4f18e92 feat: spawn the Rust companion binaries, not Python scripts
The launcher shelled out to `python3 lsx_responder_v2.py` and `python3 autopatch.py`
from a configured tools directory. Both are now Rust binaries built from this
workspace (openfut-lsx, openfut-autopatch), so the launch contract loses the
interpreter and the script directory entirely: nothing to locate, nothing to
configure, and no way to run a stale checkout's copy of a responder.

Service::script() becomes Service::binary(), and resolve_binary() prefers a sibling
of the running launcher -- what a workspace build and any sane install layout both
produce -- falling back to the bare name so a PATH install still works. It returns the
bare name rather than failing so that spawn() stays the single place a missing binary
is reported, instead of two error paths for one condition.

foreign_pid() now matches an argv entry's FILE NAME rather than a suffix, so
`/path/to/openfut-lsx` matches while an unrelated argument that merely ends with the
same text does not. It deliberately still reads argv and not comm: comm is truncated
to 15 characters by the kernel, which would misreport both of these names -- the same
trap that made an earlier `pgrep -f` guard match its own shell.

Dead configuration removed rather than left vestigial: fifa17_python and
fifa17_tools_dir, their Settings controls, and validate_local_services(), whose only
two checks were those fields. A validation hook that can only return Ok(()) would
claim the launcher verifies local-service configuration when there is none. The
preflight tools-dir gate is gone too, while the ptrace_scope check it gated is kept --
that check is real and repairable via "Arm client"; only the gate died.

The env contract is unchanged, so the binaries are drop-in: LSX still receives
FUT_PERSONA_ID/FUT_PERSONA_NAME (the persona has to agree with Blaze's
LoginResponse.SESS.PDTL and UTAS's userInfo.personaId), autopatch still receives
OPENFUT_AUTOPATCH_LOG under XDG_RUNTIME_DIR and --launcher-pid so it cannot outlive
its owner, and each companion still gets its own process group.

74 tests green.
2026-08-18 05:30:35 +00:00

941 lines
32 KiB
Rust

//! 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 {
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);
}
}