A provider plugin can report which of its titles are running #361

Merged
enricobuehler merged 3 commits from worktree-playnite-runstate into main 2026-08-20 19:22:15 +00:00
Owner

The host derives liveness by scanning (procscan + DetectSpec), which needs something recognizable on disk — an install directory, an executable, a Steam reaper. A Playnite-launched emulated game, a manually added one, or a library plugin that records no install directory has none of that, and its launch is a playnite:// hand-off, so the host holds no process either. The lease went Untracked: the exit was never noticed, session_on_game_exit (default true) could not fire, and POST /game/end had nothing to aim at.

Playnite knew the whole time. It starts the game, tracks it in the mode the person configured, and fires an event on both edges carrying the pid. That was being thrown away.

What this adds

PUT /api/v1/library/provider/{provider}/running — body {"running":[{"external_id":"…","pid":1234}]}. The live counterpart to the static detect hints a reconcile carries: that one says how to recognize a title's process, this says it is running now.

  • Declarative and idempotent, like the reconcile beside it: the body is the provider's complete running set, so a missed event, a plugin restart or an install mid-game all self-correct on the next report instead of drifting. Absent from the set = stopped.
  • Reports expire (runstate::REPORT_TTL, 90 s; the answer carries ttl_s). This is what makes it safe for a live provider to hold a streaming session open for a game the host cannot see: a plugin that dies with a game running stops counting shortly after, and the host falls back to scanning exactly as today.
  • Route authority: the plugin lane, like the reconcile. No new authority — the host maps external_id through the catalog, so a provider can only ever speak about entries it published. An unknown id is counted, not refused, because a report legitimately races its own reconcile and 400-ing the batch would throw away the liveness of every other running title (the lesson of sanitize_launcher_entries).

gamelease::LeaseKind::Reported — a lease with no process signal of its own, tracked by what its provider says.

  • open() reaches it when the spec is empty and a provider speaks for the id.
  • All three shim-reclassification paths now fall back to it where they fell to Untracked. This is the load-bearing part on Windows, where every launch recipe is a hand-off by construction (explorer.exe "playnite://…"), so a hint-less title always arrives there.
  • Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the exit. Unlike procscan::running_hint — which may only ever delay an exit, because Steam's registry flag survives an unclean one — a fresh provider report is decisive in both directions. The TTL is what buys that.
  • A reported pid joins both termination ladders on the same terms as a spawned one: re-resolved and start-time-pinned at the moment of use, so a stale or recycled pid contributes nothing.

@punktfunk/plugin-kit: ProviderClient.reportRunning, exported from the package root, version → 0.4.4 (unpublished; plugin-kit-v0.4.4 owed).

Verification

Run on the Windows CI runner, which compiles the cfg(windows) half this Mac cannot:

Gate Result
cargo check --all-targets -p punktfunk-host exit 0
cargo clippy --all-targets -- -D warnings exit 0
runstate:: 3/3
gamelease:: 13/13
mgmt::tests:: 48/49
openapi_document_is_complete_and_checked_in passes after regen

The one mgmt failure is display_monitors_answers_even_with_no_compositor, and it is A/B-proven environmental: the base commit fails it identically on the same headless box (0 passed / 1 failed, same panic).

api/openapi.json + the docs-site copy are regenerated; the delta is exactly one path and three schemas, nothing else moved.

Linux was never gated — OrbStack stayed wedged behind another session's long-running container all evening (docker ps fine, docker run --rm alpine echo hung). The only Linux-exclusive code is the 6-line targets closure in unix_term_ladder; everything else in watch() is shared with the Windows path that did compile and run.

Note

A test caught a real bug on its first run in an environment that executes tests: the three runstate tests shared a provider id and cleared the whole process-global table between cases, so under parallel scheduling they wiped each other — the same shape as mgmt's local_summary race. Each now uses ids only it touches and forgets only its own row.

The other half

punktfunk-plugin-playnite (plugin 0.4.5, exporter 0.4.0) hooks Playnite's events and reports through this route. It calls it through the kit's untyped host seam rather than reportRunning, deliberately — depending on the typed method makes that repo un-installable until the kit publishes, for the identical request. An older host 404s and the plugin carries on, so it ships independently of this.

The host derives liveness by scanning (`procscan` + `DetectSpec`), which needs something recognizable on disk — an install directory, an executable, a Steam reaper. A Playnite-launched **emulated** game, a **manually added** one, or a library plugin that records no install directory has none of that, and its launch is a `playnite://` hand-off, so the host holds no process either. The lease went `Untracked`: the exit was never noticed, `session_on_game_exit` (default **true**) could not fire, and `POST /game/end` had nothing to aim at. Playnite knew the whole time. It starts the game, tracks it in the mode the person configured, and fires an event on both edges carrying the pid. That was being thrown away. ## What this adds **`PUT /api/v1/library/provider/{provider}/running`** — body `{"running":[{"external_id":"…","pid":1234}]}`. The **live** counterpart to the static `detect` hints a reconcile carries: that one says *how to recognize* a title's process, this says *it is running now*. - **Declarative and idempotent**, like the reconcile beside it: the body is the provider's **complete** running set, so a missed event, a plugin restart or an install mid-game all self-correct on the next report instead of drifting. Absent from the set = stopped. - **Reports expire** (`runstate::REPORT_TTL`, 90 s; the answer carries `ttl_s`). This is what makes it safe for a live provider to hold a streaming session open for a game the host cannot see: a plugin that dies with a game running stops counting shortly after, and the host falls back to scanning exactly as today. - **Route authority: the plugin lane**, like the reconcile. No new authority — the host maps `external_id` through the catalog, so a provider can only ever speak about entries it published. An unknown id is *counted*, not refused, because a report legitimately races its own reconcile and 400-ing the batch would throw away the liveness of every other running title (the lesson of `sanitize_launcher_entries`). **`gamelease::LeaseKind::Reported`** — a lease with no process signal of its own, tracked by what its provider says. - `open()` reaches it when the spec is empty and a provider speaks for the id. - **All three shim-reclassification paths now fall back to it** where they fell to `Untracked`. This is the load-bearing part on Windows, where every launch recipe is a hand-off by construction (`explorer.exe "playnite://…"`), so a hint-less title *always* arrives there. - Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the exit. Unlike `procscan::running_hint` — which may only ever *delay* an exit, because Steam's registry flag survives an unclean one — a **fresh** provider report is decisive in both directions. The TTL is what buys that. - A reported pid joins both termination ladders on the same terms as a spawned one: re-resolved and start-time-pinned at the moment of use, so a stale or recycled pid contributes nothing. **`@punktfunk/plugin-kit`**: `ProviderClient.reportRunning`, exported from the package root, version → **0.4.4** (unpublished; `plugin-kit-v0.4.4` owed). ## Verification Run on the Windows CI runner, which compiles the `cfg(windows)` half this Mac cannot: | Gate | Result | |---|---| | `cargo check --all-targets -p punktfunk-host` | exit 0 | | `cargo clippy --all-targets -- -D warnings` | exit 0 | | `runstate::` | 3/3 | | `gamelease::` | 13/13 | | `mgmt::tests::` | 48/49 | | `openapi_document_is_complete_and_checked_in` | passes after regen | The one `mgmt` failure is `display_monitors_answers_even_with_no_compositor`, and it is **A/B-proven environmental**: the base commit fails it identically on the same headless box (0 passed / 1 failed, same panic). `api/openapi.json` + the docs-site copy are regenerated; the delta is exactly one path and three schemas, nothing else moved. ⚠ **Linux was never gated** — OrbStack stayed wedged behind another session's long-running container all evening (`docker ps` fine, `docker run --rm alpine echo` hung). The only Linux-exclusive code is the 6-line `targets` closure in `unix_term_ladder`; everything else in `watch()` is shared with the Windows path that did compile and run. ## Note A test caught a real bug on its first run in an environment that executes tests: the three `runstate` tests shared a provider id and cleared the whole process-global table between cases, so under parallel scheduling they wiped each other — the same shape as `mgmt`'s `local_summary` race. Each now uses ids only it touches and forgets only its own row. ## The other half `punktfunk-plugin-playnite` (plugin **0.4.5**, exporter **0.4.0**) hooks Playnite's events and reports through this route. It calls it through the kit's untyped host seam rather than `reportRunning`, deliberately — depending on the typed method makes that repo un-installable until the kit publishes, for the identical request. An older host 404s and the plugin carries on, so it ships independently of this.
enricobuehler added 3 commits 2026-08-20 18:38:58 +00:00
The host derives liveness by scanning, which needs something recognizable on
disk. A Playnite-launched emulated game, a manually added one, or a library
plugin that records no install directory has none — and its launch is a
`playnite://` hand-off, so the host holds no process either. The lease went
`Untracked`: the exit was never noticed, `session_on_game_exit` could not fire,
and `POST /game/end` had nothing to aim at. Playnite knew the whole time.

New `PUT /library/provider/{provider}/running` takes a provider's complete
running set (with the pid where it knows one) — declarative and idempotent like
the reconcile beside it, so a missed event or a plugin restart self-corrects
rather than drifting. `crate::runstate` holds it and expires it after 90s
unless restated, which is what makes it safe for a live provider to hold a
session open for a game the host cannot see: a plugin that dies stops counting
and the host falls back to scanning, exactly as today.

`LeaseKind::Reported` is the lease that follows from it. `open` reaches it when
the spec is empty and a provider speaks for the id, and — the load-bearing part
on Windows, where every launch is a hand-off by construction — the three shim
reclassification paths now fall back to it where they fell to `Untracked`.
Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the
exit. Unlike `procscan::running_hint`, which may only ever delay an exit because
Steam's registry flag survives an unclean one, a fresh report is decisive in
both directions. A reported pid joins the termination ladders on the same terms
as a spawned one: re-resolved and start-time-pinned at the moment of use.

The route is the plugin lane's, like the reconcile. No new authority — the host
maps `external_id` through the catalog, so a provider can only speak about
entries it published; an unknown id is counted, not refused, because a report
legitimately races its own reconcile and 400-ing the batch would throw away the
liveness of every other running title.

plugin-kit gains `ProviderClient.reportRunning`; a 404 from an older host means
"this host tracks games by scanning".
`reportRunning` and its two types were reachable only through the deep
`./reconcile.js` path — `index.ts` re-exports an explicit list, not a star — so
no plugin could import them from the package root the way it imports every
other provider symbol.

Version bumped because it is a published package and the addition is what a
consumer would depend on; the playnite plugin deliberately does NOT, calling the
route through the untyped host seam instead so it is not gated on this publish.
fix(host): regenerate the API spec, and stop the runstate tests colliding
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m13s
ci / docs-drift (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 2m54s
android / android (pull_request) Successful in 5m23s
ci / rust (pull_request) Successful in 7m17s
nix / flake (pull_request) Successful in 7m49s
8ff6fe6093
`api/openapi.json` (and its docs-site copy) gain exactly the new route and its
three schemas — nothing else moved, which is the check worth doing on a
regenerated spec.

The test fix is one the tests found themselves, on the first run in an
environment that actually executes them: all three shared the provider id
`playnite` and cleared the whole process-global table between cases, so under
parallel scheduling they flipped each other's answers — `omitted_is_not_running`
read `None` for a title another test had just wiped. Each now takes ids only it
uses and forgets only its own row, which also retires the blunt `reset()` that
made the collision possible.
enricobuehler merged commit 2be444b329 into main 2026-08-20 19:22:15 +00:00
enricobuehler deleted branch worktree-playnite-runstate 2026-08-20 19:22:20 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#361