ed4e3d3ab62d8a358c4e8e35dd66b0b427a0f6c7
4
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ed4e3d3ab6 |
feat(session): end-game/end-session lifetime on Windows
Phase 2. The lease, the settings, the events and the status surface were already platform-neutral; what Windows lacked was a way to see processes and a way to ask one to close. `procscan` splits per-OS behind one contract. The Windows matcher is a Toolhelp snapshot plus each process's full image path (`QueryFullProcessImageNameW`), with creation times from `GetProcessTimes` enforcing the two rules the module exists for: never adopt a process that predates the launch, never trust a bare pid. Both matter more here than on Linux — the host is SYSTEM, so it can see and signal everything, and Windows recycles pids briskly. Path matching is case-insensitive and separator-aware, and normalizes the `\\?\` prefix `canonicalize` adds, without which a store-derived path would never compare equal to a live image path. There is no reaper argv and no readable environment on Windows, so a spec carrying only a Steam appid or an env marker matches nothing rather than falling through to an empty predicate. Steam's appid instead feeds a **veto**: its per-app `Running` flag in the user's hive can't be trusted to say a game IS running (Steam sets it around updates too, and leaves it stale after a crash), but it is exactly right for refusing to declare one gone. If the matcher can't see a game whose exe sits outside its manifest's install dir, ending the session would be a false positive the player feels immediately; honoring the veto only ever leaves a stream up. Termination asks before it insists, which on Windows needs a detail that fails silently if missed: `EnumWindows` only sees the calling thread's desktop, and the host's is session 0, which holds none of the user's windows. So the terminating thread binds to the input desktop first (the pattern `pf-inject`'s `sendinput.rs` uses), posts `WM_CLOSE` to the game's visible top-level windows, waits, and only then calls `TerminateProcess` on freshly re-verified pids. Without the desktop bind the polite pass finds nothing and every game dies unsaved. Job Objects, planned as WP2.3, are deferred with the reasoning in the design doc: every Windows launch either hands off through a launcher or the shell — where a job would wrap a shim that exits immediately — or is a direct exe whose path we already know, and wiring one in would touch the `CreateProcessAsUserW` token path the UAC and secure-desktop work depends on. Also: the watcher's steady-state poll now re-verifies known pids and only re-scans the whole table once they all vanish (which still catches a game that re-execs). On Windows a full scan is an `OpenProcess` per process on the box, so this is the difference between a negligible and a noticeable poll. Gates: Linux .21 check/clippy/fmt/293 tests green; Windows .133 check + clippy --all-targets clean, and the 4 procscan tests pass — including the live one that finds the test process in the real process table and rejects a wrong creation time. On-glass Windows (Steam/Epic/GOG/Xbox titles, the unsaved-progress check) still owed; it needs a box with games on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
10902f57d0 |
test(session/gamelease): prove it against real processes, not just fixtures
The fixture tests establish that the parsing is right; they cannot establish that it describes this kernel. Two tests close that gap. procscan now scans the real /proc for a process it just started, which is the only version that would catch wrong `stat` field ordering, a broken uid check, or a mis-read uptime clock. Writing it found something worth keeping: a wrapper script that `exec`s a binary outside the install dir leaves no trace of that directory in either the image path or the command line, so the install-dir recipe cannot see it. A real game's binary lives under its install dir, so the fixture now models that instead — and the note is in the test for whoever wonders. The gamelease test drives a real child from Running through a host-requested end to Exited, and asserts the session-ending action does NOT fire for it — the difference between "the player quit" (end the session) and "we closed it" (do not). It needs ~12s to outlive the shim window and the exit confirmation, so it is #[ignore]d; run it with `-- --ignored gamelease`. Verified on .21. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a17aa61c5a |
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> |
||
|
|
64c0ff96bc |
feat(session): bind a session's life to its game's, in both directions (Linux)
Two of the most-asked-for behaviors, and until now the host could only manage a
sliver of one of them: end the streaming session when the launched game exits,
and — when the operator asks — end the game when the session ends.
Ending the session on game exit already worked, but only for a Steam title under
a bare-spawn gamescope. Everywhere else the host spawned a launch and forgot it:
the pid was logged and dropped, so on KWin, Mutter, wlroots or gamescope-attach a
finished game left the client staring at a desktop. Ending a game was not
possible at all — there was no primitive for it.
The missing piece was never plumbing, it was knowledge: a launch says what to
run, not what the game looks like once a launcher has handed off. So each store
now also reports how to recognize its game (DetectSpec, previous commit's
subject matter, folded in here), procscan turns that into live pids on Linux, and
gamelease turns pids into a lifetime — with four kinds covering how the game got
started:
nested gamescope owns it; its display teardown ends the game
child the host spawned it, in its own process group
matched a launcher owns it; recognized by its store's signals
untracked nothing identifies it — both behaviors stay off, and the host
says so once rather than guessing
A child that exits successfully within 5s was a launcher handing off, not the
game: the lease re-resolves to matched (or to untracked) instead of reporting an
exit. That single rule is what keeps steam://, epic:// and playnite:// launches
from ending a session the moment they start.
Ending a game is destructive, so three rules bound it. It is opt-in
(game_on_session_end defaults to keep). A drop is not a decision — `always`
waits out a reconnect window (5 min) that a returning client cancels by
reclaiming its own game, matched on fingerprint and title. And only ever this
session's game: a pid is adopted only if it started after the launch, so a copy
the player already had open is never touched, and every pid is re-verified
against its start time immediately before being signalled, so a recycled pid
never is. Termination asks first (SIGTERM to the group, 10s) and only then
insists.
Also here, because they are the same decision seen from other angles:
- A management stop is now a deliberate stop. `DELETE /session` sets quit before
stop, so it behaves like a client pressing Stop — the display skips its
keep-alive linger instead of lingering for a session nobody is coming back to.
- `/status` reports what is running (games[]), including a game whose session has
gone and which is waiting out its window, so the console can show it and offer
`POST /game/end`.
- New game.running / game.exited events, filterable like every other kind, which
is the whole polling loop of the community plugin this replaces.
- session_status::register takes a named struct: eleven positional arguments,
half of them same-typed Arc<Atomic…>, is a transposition waiting to happen.
Gates on Linux (.21): check, clippy --all-targets, fmt, and 291 tests green.
Windows (Job Objects, Toolhelp matching) and the GameStream plane are phases 2
and 3; both are inert here, as is macOS, which has no launch path at all.
Design + plan: punktfunk-planning/design/session-game-lifetime{,-implementation-plan}.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|