Skip to main content

The poll loop

src/main.rs· 98 lines

Everything SteamLogger does at runtime happens inside one loop in main::run. This page walks through it in execution order.

Startup​

src/main.rs (abridged)
let client = SteamClient::new(config.api_key.clone());
let mut a2s = A2sClient::new();
let mut tracker = Tracker::new(storage::read_log(&config.output_file).unwrap_or_default());
let interval = Duration::from_secs(config.poll_interval_secs.max(5));

let (tx, rx) = mpsc::channel::<()>();
ctrlc::set_handler(move || { let _ = tx.send(()); })?;
println!("SteamLogger running. Log file: {} (Ctrl+C to stop)", config.output_file.display());
StepDetail
Build SteamClient10-second HTTP timeout, User-Agent: SteamLogger/0.1 (github.com/steamlogger), connection pool reused for the whole run.
Build A2sClient3-second UDP read timeout; no socket is created yet.
Load existing logread_log(...).unwrap_or_default() — an unreadable or malformed file yields an empty log (consequence).
Build TrackerInspects the last entry: end: null means "resume", otherwise "idle".
Clamp the intervalpoll_interval_secs.max(5) — the effective floor is 5 seconds.
Install the signal handlerA SIGINT handler sends () down an mpsc channel. Failure to install is fatal (exit code 1).

One tick​

1. Check for shutdown​

rx.try_recv() is non-blocking. If Ctrl+C arrived during the previous tick's work, the loop breaks before making another network call.

2. Ask Steam what is running​

client.current_game(config.steam_id) performs one GetPlayerSummaries request for a single SteamID and maps the row through summary_to_current_game:

  • Ok(Some(game)) → in game.
  • Ok(None) → the profile reports no gameid (not playing, or game details are not public).
  • Err(e) → network/HTTP/JSON failure. Printed as poll error: …; the tracker is not touched, so an outage never fabricates a session end.

3. Update the state machine​

tracker.update(&game, &now()) returns Started(i) or Running(i) and handles game switches; tracker.end_active(&now()) closes an open session. Full rules in Session lifecycle.

The timestamp is computed in main (now()), not inside the tracker, so the state machine stays pure and testable.

4. Enrich (optional)​

When enrich = true and a session is active, enrich::analyze collects friends, lobby members, server name and map, then tracker.apply_meta writes them onto the active entry. analyze never returns an error — a failure just produces empty data. Only apply_meta can fail (index out of bounds), which is reported as enrichment failed: ….

5. Persist​

storage::write_log(&config.output_file, tracker.log()) serialises the whole log and renames it into place. A write failure prints failed to save log: … and the loop continues — the in-memory log is still correct and the next tick retries.

6. Sleep — interruptibly​

let _ = rx.recv_timeout(interval);

The loop blocks on the channel rather than thread::sleep, so Ctrl+C ends the wait immediately instead of after up to poll_interval_secs. The received value (if any) is discarded here; the try_recv at the top of the next iteration is what actually breaks the loop.

Shutdown​

if let Err(e) = storage::write_log(&config.output_file, &tracker.into_log()) {
eprintln!("failed to save log: {e}");
}
println!("stopped");
ExitCode::SUCCESS

into_log() consumes the tracker and hands over the GameLog for one last write. Note what shutdown deliberately does not do: it never calls end_active. A session running at shutdown stays open so the next run can resume it.

Cost per tick​

OperationCountNotes
GetPlayerSummaries (self)1Always.
GetFriendList1Only when enrich = true and you are in a game.
GetPlayerSummaries (friends)ceil(friends / 100)Steam caps 100 SteamIDs per request; SteamClient::summaries chunks automatically.
A2S_INFO UDP round trip0–2 packetsOnly when Steam reports a gameserverip; a challenge reply costs one extra exchange.
Log file rewrite1Whole file, pretty-printed.

With the default 30-second interval, 200 friends and a game running all day, that is roughly 2 880 × (1 + 1 + 2) ≈ 11 520 Steam API calls per day — comfortably under Steam's 100 000/day courtesy limit, but worth keeping in mind if you lower the interval. Idle time is cheaper: one call per tick.

Timing characteristics​

  • The interval is the gap between ticks, not a fixed period: the sleep starts after the work finishes, so a slow tick shifts the schedule instead of queuing.
  • A hung network call is bounded by the 10-second HTTP timeout; an unreachable game server by the 3-second UDP read timeout.
  • Worst-case tick duration with enrichment is therefore roughly 10 s (self) + 10 s (friends) + 10 s (summaries) + 3 s (A2S) before the sleep begins.
  • Session boundaries are quantised to the interval — see Log format → Timestamps.

Exit codes​

CodeCause
0Clean shutdown after Ctrl+C.
1Config could not be loaded, or the SIGINT handler could not be installed.

Nothing else terminates the process on purpose; recoverable errors are printed and skipped.