fix(session/gamelease): four things the first pass got wrong

Found reviewing the riskiest paths of the previous commit.

- An install dir of `/games/x` matched a process running out of `/games/xyz`.
  The image-path check was already component-wise and fine; the command-line
  check compared raw bytes, so it needed the separator. Two library folders
  where one name prefixes the other is not a contrived case.
- Ending a nested game force-releases kept displays, which is not per-display —
  so with another client streaming it could retire a display that was never
  ours. Skip it while anyone else has a live session: the failure mode becomes a
  game that keeps running, which is the default everywhere else anyway.
- A lease nothing polls (no detect signals, or a platform without a matcher yet)
  sat at "launching" forever in the console. Report it as running: the host did
  just launch it, and with no watcher it is claiming nothing about the exit.
- A game ended after its session was already gone reported no `game.exited` at
  all — its watcher had stopped with the session, so nothing was left to emit
  it. That is every grace expiry and every `POST /game/end`, i.e. exactly the
  ones an operator most wants to see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:28:13 +02:00
co-authored by Claude Fable 5
parent 64c0ff96bc
commit a17aa61c5a
2 changed files with 68 additions and 10 deletions
+29 -4
View File
@@ -192,11 +192,16 @@ impl Scanner {
}
}
if let Some(dir) = install_dir {
// Require a path separator after the directory, so an install dir of `/games/x` is
// not satisfied by an unrelated `/games/xyz/…` argument. (The image-path check above
// needs no such care — `Path::starts_with` already compares whole components.)
let needle = dir.as_os_str().as_encoded_bytes();
for arg in cmdline.split(|&b| b == 0) {
if arg.starts_with(needle) {
return true;
}
let under_dir = |arg: &[u8]| {
arg.strip_prefix(needle)
.is_some_and(|rest| rest.first() == Some(&b'/'))
};
if cmdline.split(|&b| b == 0).any(under_dir) {
return true;
}
}
if let Some(want) = exe {
@@ -376,6 +381,26 @@ mod tests {
);
}
#[test]
fn install_dir_does_not_match_a_sibling_with_the_same_prefix() {
// `/games/x` must not adopt a process running out of `/games/xyz` — a real hazard when one
// library folder's name is a prefix of another's.
let td = fake_proc_root(
1000.0,
&[
FakeProc::new(90, 50_000).exe("/games/xyz/other"),
FakeProc::new(91, 50_000).cmdline(&["wrapper", "/games/xyz/other"]),
],
);
let s = scanner(td.path());
assert!(s.find(&DetectSpec::dir("/games/x"), None).is_empty());
// The genuine directory still matches, by image path and by argument.
assert_eq!(
pids(s.find(&DetectSpec::dir("/games/xyz"), None)),
vec![90, 91]
);
}
#[test]
fn matches_proton_style_game_only_in_the_cmdline() {
// The image is the Proton runtime; the game appears only as an argument.