Compare commits

...
Author SHA1 Message Date
enricobuehler c9a76287d8 fix(console-ui): the layer under a card takes the card's corner plus its own outset
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 42s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 5m34s
ci / rust (pull_request) Successful in 5m45s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m9s
The focus halo grows the card's rect by 4 design units on every side but drew
it with the card's own corner radius. A shape grown by `d` keeps its corners
parallel to the original's only if its radius grows by `d` too — otherwise the
two arcs stop sharing a centre. So the halo came out squarer than the card it
sits under: clean along the edges, visibly misaligned at the four corners,
where it read as a badly drawn outline rather than as light spilling out.

Same rule applied to `panel_highlight`, which pulls in half a unit and kept the
full radius. `drop_shadow` only offsets, so its geometry was already right, and
the collections plate uses `RRect::with_outset`, which adjusts the radii itself.

Every card in the console goes through these two helpers — the home tiles, the
library grid, the coverflow, the collections deck.
2026-08-20 21:44:55 +02:00
enricobuehler cbd3d02817 fix(clients/android): the console's Android rows were nested where serde flattens them
Turning "Controller-optimized UI" off in the console did nothing: the console
stayed up, because the setting never left the console.

`trust::Settings::extra` is `#[serde(flatten)]`, so the `android.*` keys are
TOP-LEVEL keys of the settings document, beside `width` and `codec`.
`ConsoleJson` wrote and read them nested under an `"extra"` object instead.
Serde put that whole object into the map under the literal key `"extra"`, so
no console row ever found `android.gamepad_ui_enabled` — the row showed its
own default, and the value the console saved came back to Kotlin as the one
Kotlin had just sent. `applySettings` then saw no change, raised no callback,
and `App` never recomputed `gamepadUiActive`.

Every Android-only row rode the same broken path: low latency, phone
rumble/gyro, SC2 and DualSense capture, and the console-UI mode picker.

A store written by the nesting build carries the dead wrapper; it is dropped
on the next write rather than echoed for the life of the install.

The new test pins the shape from both sides. A round-trip alone could not have
caught this — both halves agreed on the same wrong nesting, which is exactly
how it survived review.
2026-08-20 21:44:46 +02:00
enricobuehler 2be444b329 Merge pull request 'A provider plugin can report which of its titles are running' (#361) from worktree-playnite-runstate into main
ci / docs-site (push) Successful in 1m14s
ci / web (push) Successful in 1m19s
ci / bun-nix (push) Successful in 1m24s
ci / docs-drift (push) Successful in 27s
deb / build-publish-gamescope (push) Successful in 43s
ci / rust-arm64 (push) Successful in 3m26s
deb / build-publish-client-arm64 (push) Successful in 1m20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m16s
nix / flake (push) Successful in 20m48s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m59s
ci / rust (push) Successful in 8m24s
arch / build-publish (push) Successful in 8m31s
deb / build-publish-host (push) Successful in 7m6s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 39s
plugin-kit-publish / publish (push) Successful in 41s
android / android (push) Successful in 12m41s
windows-host / package (push) Successful in 13m2s
windows-host / winget-source (push) Skipped
deb / smoke-install (push) Successful in 4m17s
windows-host / canary-manifest (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m45s
Reviewed-on: #361
2026-08-20 19:22:10 +00:00
enricobuehler 669a1bc0ce The video data port was never open, and nothing could tell us (#360)
ci / docs-drift (push) Successful in 29s
ci / bun-nix (push) Successful in 53s
ci / docs-site (push) Successful in 1m13s
ci / web (push) Successful in 1m17s
apple / swift (push) Failing after 1m34s
apple / distribute (push) Skipped
apple / screenshots (push) Skipped
ci / rust-arm64 (push) Successful in 1m52s
deb / build-publish-gamescope (push) Successful in 1m9s
decky / build-publish (push) Successful in 1m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
deb / build-publish-client-arm64 (push) Successful in 1m55s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 44s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m59s
deb / build-publish-host (push) Successful in 6m4s
ci / rust (push) Successful in 7m23s
arch / build-publish (push) Successful in 8m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m14s
android / android (push) Successful in 10m46s
flatpak / build-publish (push) Successful in 7m15s
deb / build-publish (push) Successful in 5m6s
deb / smoke-install (push) Successful in 3m49s
windows-host / package (push) Successful in 13m18s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m56s
windows-host / canary-manifest (push) Successful in 27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
Merged with `apple / swift` red: the Mac runner's volume is out of space (594 "No space left on device" lines; the only distinct error is "You can't save the file because the volume 'Macintosh HD' is out of space"). It passed on the parent commit 1280f697 and the only delta since was one line in scripts/ci/docs-undocumented-env-baseline.txt — Swift compilation of the Apple client cannot be affected by a Rust host firewall rule, a core control message, or a CI text baseline. A rerun hit the same wall, so the mac-mini-1 disk needs the owed cleanup.

Every check that can validate this change is green: ci/rust (480 core tests), ci/rust-arm64, android, docs-drift, web, docs-site, bun-nix, and both windows-client legs. The Windows HOST build — which the PR checks never run, and which is the only thing that compiles the cfg(windows) firewall function — was dispatched separately on the branch and passed, including "Clippy (host + tray, Windows)".
2026-08-20 18:48:28 +00:00
enricobuehler 8ff6fe6093 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
`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.
2026-08-20 20:38:08 +02:00
enricobuehler 1758266bda chore(plugin-kit): export the running-report surface, and bump to 0.4.4
`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.
2026-08-20 20:38:08 +02:00
enricobuehler d5fb1e4479 feat(host): a provider plugin can report which of its titles are running
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".
2026-08-20 20:38:08 +02:00
enricobuehler ea3c9e1202 ci(docs-drift): baseline the new control-message constant
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m14s
ci / docs-drift (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m42s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m22s
apple / swift (pull_request) Failing after 1m35s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m49s
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and asks that
each be documented in docs-site or explicitly baselined. It cannot tell
an env knob from a cbindgen-exported `#define`, so the new
`PUNKTFUNK_MSG_DELIVERY_REPORT` header constant tripped it.

It is a wire message type byte, not a knob an operator can set — same as
every other `PUNKTFUNK_MSG_*`, all of which are already in the baseline.
Added in sorted position beside them.
2026-08-20 20:27:37 +02:00
enricobuehler 685c4bd99a Merge pull request 'A pad that was never there kept the console UI on, and the picture sat in the corner' (#359) from worktree-android-gamepad-ui-and-corner into main
ci / rust-arm64 (push) Successful in 2m19s
ci / web (push) Successful in 1m28s
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
ci / docs-site (push) Successful in 2m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 25s
ci / rust (push) Successful in 8m23s
docker / deploy-docs (push) Successful in 1m43s
android / android (push) Successful in 10m20s
2026-08-20 18:22:33 +00:00
enricobuehler 1280f697be fix(host,core): the video data port was never open, and nothing could tell
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m53s
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / web (pull_request) Successful in 1m29s
ci / docs-site (pull_request) Successful in 58s
ci / docs-drift (pull_request) Failing after 28s
ci / bun-nix (pull_request) Successful in 56s
android / android (pull_request) Successful in 7m21s
ci / rust (pull_request) Canceled after 7m48s
A field host streamed 1919 frames into a black screen while its own log
blamed the client. Four faults, each of which alone makes the failure
invisible.

The Windows firewall rules are `localport=`-scoped (47998-48010, 9777,
5353), but the media data plane binds an EPHEMERAL port per session. No
such rule can ever cover it, so Windows Firewall drops the client's
hole-punch on EVERY session on EVERY Windows host — `punched=false` on
the "data plane bound" line, in all six sessions of two field logs. The
punch then never opens the return path and video is sent blind to an
address the client merely reported. `service install` now also adds a
program-scoped inbound UDP rule for the host executable, which covers
whatever port a session picks. Program-scoped rather than a pinned port:
pinning into 47998-48010 would collide with Sunshine/Apollo.

`LossReport` carried only `loss_ppm`, which is ambiguous at zero — loss
is a ratio over the packets that arrived, so a flawless link and a link
delivering NOTHING both report 0. The host read total silence as perfect
and decayed adaptive FEC to its floor. Clients now also send a
`DeliveryReport` with the session's received-packet count. It is a new
type byte, NOT a field appended to `LossReport`: that message is
length-checked exactly, so lengthening it would make every shipped host
reject the loss reports its FEC runs on. Sent every window while the
count is zero, once when the first packets land, then never — an older
host warns per unknown message and must not be flooded on a good
session.

`NO_VIDEO_RETRY` (client got nothing) and `FLUSH_COOLDOWN` (client
drowning) were both 2000 ms, so the host's cadence classifier could not
tell two opposite faults apart and picked the wrong one out loud. The
no-video cooldown moves to core beside `FLUSH_COOLDOWN` at 2600 ms, and
both sides compare against the shared constant rather than a copy.

The diagnosis now leads with the delivery count: zero is an error naming
the data plane, a confirmed count keeps the old confident wording, and an
old client that cannot answer gets a warning that says so instead of
guessing. A punch that never arrives is also its own warning now, rather
than a debug field on an info line.
2026-08-20 19:14:49 +02:00
32 changed files with 1533 additions and 77 deletions
+49
View File
@@ -537,6 +537,55 @@ legs as follow-ups. Both landed here.
ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the
upload rides the client's own mTLS.
### A provider plugin can report which of its titles are **running**
New: `PUT /api/v1/library/provider/{provider}/running`, body
`{"running":[{"external_id":"…","pid":1234}]}` — the **live** counterpart to the static `detect`
hints a reconcile carries. `detect` says *how to recognize* a title's process; this says *it is
running now*, and carries the pid where the provider knows one. Additive: no existing route,
payload or behaviour changes, and a host with no reporting plugin behaves exactly as before.
It exists because one class of title could never be tracked at all. 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`, its 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 — 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.
- **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 self-correct
on the next report instead of drifting. Absent from the set = stopped.
- **Reports expire** (`crate::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. Reporters must restate well inside the window.
- **New `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;
the shim-reclassification paths (every Windows launch is a hand-off by construction) fall back to
it too, where they previously fell to `Untracked`. Phase 1 accepts "running" as the game
appearing; phase 2 treats "stopped" as the exit, and — unlike `procscan::running_hint`, which may
only ever *delay* an exit because Steam's registry flag survives an unclean exit — a fresh
provider 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).
- **Route authority**: the plugin lane, like the reconcile (`mgmt::auth::plugin_may_access`, and its
exhaustive classification table). 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.
- **`@punktfunk/plugin-kit`: `ProviderClient.reportRunning(providerId, running)`**, returning
`{matched, unknown, ttlS}`; a 404 from an older host means "this host tracks games by scanning".
Version bumped to **0.4.4** — **unpublished, `plugin-kit-v0.4.4` owed.**
The Playnite half lives in `punktfunk-plugin-playnite` (**0.4.5**, exporter **0.4.0**): the C#
exporter hooks Playnite's `OnGameStarted`/`OnGameStopped`/`OnGameStartupCancelled` and writes a
small `punktfunk-running.json` beside the library export, re-stamped every 30 s and *deleted* when
Playnite closes; the plugin polls it and restates the set to this route. It calls the route through
the kit's untyped host seam rather than `reportRunning`, deliberately — depending on the method
would make that repo unbuildable until the kit publishes, for the same request. Needs a host
carrying this route; an older one 404s and the plugin carries on without it.
### Everything else an integrator might notice
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
+125
View File
@@ -1860,6 +1860,69 @@
}
}
},
"/api/v1/library/provider/{provider}/running": {
"put": {
"tags": [
"library"
],
"summary": "Report which of a provider's titles are running",
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
"operationId": "reportProviderRunning",
"parameters": [
{
"name": "provider",
"in": "path",
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The report was accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningAccepted"
}
}
}
},
"400": {
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/scanners": {
"get": {
"tags": [
@@ -7792,6 +7855,46 @@
}
}
},
"ProviderRunningAccepted": {
"type": "object",
"description": "The result of a liveness report.",
"required": [
"matched",
"unknown",
"ttl_s"
],
"properties": {
"matched": {
"type": "integer",
"description": "How many reported titles matched an entry this provider currently publishes.",
"minimum": 0
},
"ttl_s": {
"type": "integer",
"format": "int64",
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
"minimum": 0
},
"unknown": {
"type": "integer",
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
"minimum": 0
}
}
},
"ProviderRunningInput": {
"type": "object",
"description": "Request body for `reportProviderRunning`.",
"properties": {
"running": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunningTitle"
},
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
@@ -7846,6 +7949,28 @@
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
"required": [
"external_id"
],
"properties": {
"external_id": {
"type": "string",
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
},
"pid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
"minimum": 0
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
@@ -317,16 +317,20 @@ internal object ConsoleJson {
j.put("invert_scroll", s.invertScroll)
j.put("pad_haptics", s.padHaptics)
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
val extra = j.optJSONObject("extra") ?: JSONObject()
extra.put("android.low_latency", s.lowLatencyMode)
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
extra.put("android.gyro_on_phone", s.gyroOnPhone)
extra.put("android.sc2_capture", s.sc2Capture)
extra.put("android.ds_capture", s.dsCapture)
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
extra.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
j.put("extra", extra)
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
// whole object into the map under the literal key "extra", where no console row could
// read it and every value the console wrote came straight back as the one we had sent.
j.put("android.low_latency", s.lowLatencyMode)
j.put("android.rumble_on_phone", s.rumbleOnPhone)
j.put("android.gyro_on_phone", s.gyroOnPhone)
j.put("android.sc2_capture", s.sc2Capture)
j.put("android.ds_capture", s.dsCapture)
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
// A store written by the nesting build carries the stale wrapper; drop it rather than
// round-trip a copy of these keys that nothing reads for the life of the install.
j.remove("extra")
return j
}
@@ -336,7 +340,8 @@ internal object ConsoleJson {
*/
fun applySettings(s: Settings, j: JSONObject): Settings {
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
val extra = j.optJSONObject("extra") ?: JSONObject()
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
return s.copy(
width = j.optInt("width", s.width),
height = j.optInt("height", s.height),
@@ -373,14 +378,14 @@ internal object ConsoleJson {
"off" -> false
else -> s.padSpeaker
},
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
.ifEmpty { s.gamepadUiMode },
gamepadUiEnabled = extra.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
)
}
}
@@ -0,0 +1,73 @@
package io.unom.punktfunk
import io.unom.punktfunk.console.ConsoleJson
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
*
* They were written and read nested under an `"extra"` object instead. Serde put that whole
* object into the map under the literal key `"extra"`, so no console row ever found
* `android.gamepad_ui_enabled` — and the value the console saved came back to Kotlin as the one
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
* with nothing happening: the console stayed up, because the setting never moved.
*/
class ConsoleSettingsExtraTest {
@Test
fun androidKeysAreWrittenFlat() {
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.low_latency"))
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
}
/** A store written by the nesting build must not keep echoing its dead wrapper. */
@Test
fun aStaleNestedWrapperIsDropped() {
val base = JSONObject().put(
"extra",
JSONObject().put("android.gamepad_ui_enabled", true),
)
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
}
@Test
fun theConsolesOwnSaveIsReadBack() {
val saved = JSONObject()
.put("android.gamepad_ui_enabled", false)
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
.put("android.ds_capture", false)
val next = ConsoleJson.applySettings(Settings(), saved)
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
assertFalse(next.dsCapture)
}
/** Both halves against each other — the shape only holds if they agree. */
@Test
fun theRoundTripKeepsEveryAndroidRow() {
val want = Settings(
gamepadUiEnabled = false,
gamepadUiMode = GAMEPAD_UI_ALWAYS,
lowLatencyMode = false,
rumbleOnPhone = true,
gyroOnPhone = true,
sc2Capture = false,
dsCapture = false,
)
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
assertEquals(want.sc2Capture, got.sc2Capture)
assertEquals(want.dsCapture, got.dsCapture)
}
}
+8 -1
View File
@@ -91,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
///
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
+30 -3
View File
@@ -53,9 +53,9 @@ use punktfunk_core::config::Role;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::FLAG_PROBE;
use punktfunk_core::quic::{
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
Welcome,
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, DeliveryReport, Hello,
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate,
Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
@@ -987,10 +987,18 @@ async fn session(args: Args) -> Result<()> {
let mut ls = send;
let lp = loss_ppm.clone();
let df = dropped_frames.clone();
// Delivery truth for the host's dead-data-plane check: report what actually landed on the
// wire, so the probe reproduces a real client's answer rather than the "cannot answer"
// sentinel — which is exactly what makes it usable for testing that path.
let rxp = rx_wire_packets.clone();
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
let mut last_report = std::time::Instant::now();
let mut last_dropped = 0u64;
// Mirrors the real clients' rule (see `pump/data.rs`): report the delivery count every
// window while it is zero, once when the first packets land, then stop — so a host that
// predates the message is not flooded with "unknown control message" on a good session.
let mut delivery_confirmed = false;
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let d = df.load(Relaxed);
@@ -1007,6 +1015,25 @@ async fn session(args: Args) -> Result<()> {
if last_report.elapsed() >= std::time::Duration::from_millis(750) {
last_report = std::time::Instant::now();
let v = lp.swap(u32::MAX, Relaxed);
// Independent of whether there is a fresh loss sample: "no fresh sample" is
// exactly the shape a dead data plane has, so gating it on one would silence
// it in the state it exists to report.
let received = rxp.load(Relaxed);
if received == 0 || !delivery_confirmed {
delivery_confirmed = received > 0;
if io::write_msg(
&mut ls,
&DeliveryReport {
packets_received: received,
}
.encode(),
)
.await
.is_err()
{
break; // control stream gone
}
}
if v != u32::MAX
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
.await
+15 -3
View File
@@ -396,9 +396,16 @@ pub(crate) fn panel_highlight(canvas: &Canvas, rect: Rect, corner: f32, k: f32)
),
None,
));
canvas.draw_rrect(RRect::new_rect_xy(inset, corner * k, corner * k), &p);
// Concentric, the same rule the halo states: pulled in by half a unit, so the radius
// comes in by half a unit too or the lit edge crosses the panel's own corner arc.
let r = ((corner - 0.5) * k).max(0.0);
canvas.draw_rrect(RRect::new_rect_xy(inset, r, r), &p);
}
/// How far [`focus_halo`] is grown past the card on every side, in design units. Both the
/// rect AND the corner radius take it — see the draw there.
const HALO_OUTSET: f32 = 4.0;
/// An accent-tinted glow under the focused card — the palette-aware mark that says "this
/// one" from across a room, where a 2 % scale difference says nothing at all. Drawn behind
/// [`drop_shadow`], and only ever for the ONE focused tile, so it costs a single extra
@@ -439,8 +446,13 @@ pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f3
// it overran the coverflow's 58 dp focused-to-neighbour gap, and since the strip paints
// farthest-first the focused card's corona landed on top of its neighbours — which is
// what made every card look like it was glowing.
let spread = rect.with_outset((4.0 * k, 4.0 * k));
canvas.draw_rrect(RRect::new_rect_xy(spread, corner * k, corner * k), &p);
let spread = rect.with_outset((HALO_OUTSET * k, HALO_OUTSET * k));
// Concentric: a shape grown by `d` on every side keeps its corners parallel to the
// original's only if its radius grows by `d` too (the two arcs then share a centre).
// Reusing the card's own radius left the halo squarer than the card it sits under, so
// it read as a misaligned outline at the four corners and a clean glow along the edges.
let r = (corner + HALO_OUTSET) * k;
canvas.draw_rrect(RRect::new_rect_xy(spread, r, r), &p);
}
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
+1
View File
@@ -215,6 +215,7 @@ include = ["PunktfunkEndReason"]
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
"MSG_DELIVERY_REPORT" = "PUNKTFUNK_MSG_DELIVERY_REPORT"
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
+7 -1
View File
@@ -1,7 +1,9 @@
//! `CtrlRequest` (the embedder's control-stream requests) and `Negotiated` (the handshake result).
use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::quic::{ClipControl, ClipOffer, ColorInfo, LossReport, ProbeRequest, RfiRequest};
use crate::quic::{
ClipControl, ClipOffer, ColorInfo, DeliveryReport, LossReport, ProbeRequest, RfiRequest,
};
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
/// speed test. One outbound channel carries both so the worker's `select!` has a single writer
@@ -15,6 +17,10 @@ pub(crate) enum CtrlRequest {
/// forcing a full IDR. See [`RfiRequest`].
Rfi(RfiRequest),
Loss(LossReport),
/// How many data-plane packets have reached us all session — sent straight after every
/// [`CtrlRequest::Loss`], because `loss_ppm` is ambiguous at zero (no loss and no packets look
/// identical) and only this separates them. See [`DeliveryReport`].
Delivery(DeliveryReport),
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
/// [`BitrateController`] when the user's bitrate setting is Automatic.
SetBitrate(u32),
@@ -57,6 +57,21 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
/// the number, so the two can never drift apart.
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// Spacing of a client's keyframe re-asks while it has received **no video at all** — the other
/// reason a client asks on a perfectly fixed cadence, and the OPPOSITE fault to [`FLUSH_COOLDOWN`]'s
/// (nothing arriving, versus more arriving than it can drain).
///
/// **Public, and deliberately a different value, for the same reason [`FLUSH_COOLDOWN`] is public.**
/// While both were 2000 ms the host's recovery-cadence detector could not tell which failure it was
/// looking at, and reported the confident wrong one: a 2026-08-20 field case where not one byte of
/// video ever reached the client was diagnosed for days as a client too slow to keep up. Embedders
/// own the no-video timer (it lives in each decode loop), so this is the value they must use — a
/// local copy is exactly the drift that made the two indistinguishable in the first place.
///
/// The delivery count on [`crate::quic::LossReport`] settles it outright for clients new enough to
/// send one; this keeps the period itself informative for those that are not.
pub const NO_VIDEO_RETRY: Duration = Duration::from_millis(2600);
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
+1 -1
View File
@@ -42,7 +42,7 @@ mod recovery;
mod rumble;
mod worker;
pub use self::frame_channel::FLUSH_COOLDOWN;
pub use self::frame_channel::{FLUSH_COOLDOWN, NO_VIDEO_RETRY};
pub use self::planes::AudioPacket;
pub use self::probe::ProbeOutcome;
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
+3 -3
View File
@@ -11,9 +11,9 @@ use crate::abr::BitrateController;
use crate::config::Role;
use crate::packet::FLAG_PROBE;
use crate::quic::{
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
ResyncStep, SetBitrate, Start, Welcome,
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync,
DeliveryReport, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
ResyncAdmit, ResyncGuard, ResyncStep, SetBitrate, Start, Welcome,
};
use crate::session::Session;
use crate::transport::UdpTransport;
@@ -107,6 +107,7 @@ impl ControlTask {
}
CtrlRequest::Rfi(r) => r.encode(),
CtrlRequest::Loss(r) => r.encode(),
CtrlRequest::Delivery(r) => r.encode(),
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
CtrlRequest::ClockResync => {
if clock_rtt_ns.is_none() {
+77 -2
View File
@@ -77,6 +77,12 @@ impl DataPump {
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
let mut last_report = Instant::now();
// Has the host been told, once, that data-plane packets are reaching us? See the send site:
// the delivery count is reported every window while it is ZERO (the state the host acts on)
// and once more when the first packets land, then never again. A host that predates the
// message logs "unknown control message" for each one, so a healthy session must not stream
// them — one line per session is a fair price on an old host, eighty a minute is not.
let mut delivery_confirmed = false;
let (
mut last_recovered,
mut last_late,
@@ -415,6 +421,27 @@ impl DataPump {
);
} else {
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
// Rides with the loss report — it is what makes `loss_ppm = 0` readable at the
// host, which cannot otherwise tell a flawless link from one delivering
// nothing. The session TOTAL, not this window's, so one message stands on its
// own. Deliberately inside the same arm: a discarded window is discarded
// because the host was rebuilding or a probe distorted it, and staying silent
// there keeps that contract exact. Nothing is lost — the state this reports
// (no packets at all) produces no discards, so its windows always send.
//
// Sent every window while the count is ZERO, then ONCE when the first packets
// land (so the host stops guessing and can name the other failure confidently),
// then never again: a healthy session must not stream a message that older
// hosts log as unknown on every arrival.
// ponytail: only start-of-session death is covered. A path that dies MID-stream
// leaves the count frozen above zero and silent, which the host still reads as
// healthy — detecting that needs a stalled-counter check with its own timing,
// worth adding if a mid-session case is ever reported.
if should_report_delivery(st.packets_received, &mut delivery_confirmed) {
let _ = ctrl_tx.try_send(CtrlRequest::Delivery(DeliveryReport {
packets_received: st.packets_received,
}));
}
}
// Standing-latency bleed: close the detector's window with this report's loss
// verdict and run its escalation ladder — re-sync first (free; a stale offset
@@ -757,10 +784,58 @@ fn take_pipeline_gap(slot: &AtomicU32) -> Option<u32> {
}
}
/// Does this report window owe the host a [`DeliveryReport`], and record that it has been told?
///
/// Every window while `packets_received` is ZERO — that is the state the host escalates on, and it
/// must keep hearing it — then exactly ONCE more when the first packets land, so the host learns
/// delivery works and can stop hedging its stall diagnosis. Silent after that: a host that predates
/// the message logs every unknown control message, and a healthy hours-long session must not fill
/// its log with them.
fn should_report_delivery(packets_received: u64, confirmed: &mut bool) -> bool {
let owed = packets_received == 0 || !*confirmed;
*confirmed = packets_received > 0;
owed
}
#[cfg(test)]
mod tests {
use super::*;
/// The host must keep hearing "zero" for as long as it is true (that is the black-screen
/// signal), get exactly one confirmation when video starts, and then silence — the noise budget
/// on an older host, which warns per unknown message, is what pays for the first two.
#[test]
fn the_delivery_count_is_reported_while_zero_then_once_more_and_never_again() {
let mut confirmed = false;
// Nothing arriving: reported every window, for as long as it stays true.
for _ in 0..5 {
assert!(
should_report_delivery(0, &mut confirmed),
"a dead data plane must be re-reported every window"
);
}
// First packets land: one confirmation, so the host can name the other failure confidently.
assert!(should_report_delivery(500, &mut confirmed));
// Healthy from here: silent.
for n in [900, 1_200, 90_000] {
assert!(
!should_report_delivery(n, &mut confirmed),
"a healthy session must not stream delivery reports"
);
}
}
/// A session that never receives anything must never look confirmed, no matter how long it runs
/// — the whole point is that the host keeps being told.
#[test]
fn a_session_that_receives_nothing_never_reports_itself_healthy() {
let mut confirmed = false;
for _ in 0..100 {
assert!(should_report_delivery(0, &mut confirmed));
assert!(!confirmed);
}
}
#[test]
fn a_pipeline_gap_is_taken_exactly_once() {
let slot = AtomicU32::new(0);
@@ -935,8 +1010,8 @@ mod tests {
.expect("the window after the gap reports on schedule");
assert!(
matches!(reported, Some(CtrlRequest::Loss(_))),
"the window after the gap must produce a loss report — an idle session's only \
outbound request"
"the window after the gap must produce a loss report — the first of the two requests \
an idle session makes (the delivery count follows it)"
);
assert!(
started.elapsed() >= Duration::from_millis(1_400),
+84
View File
@@ -97,6 +97,33 @@ pub struct LossReport {
pub loss_ppm: u32,
}
/// `client → host`, sent immediately after each [`LossReport`]: data-plane packets this client has
/// received all session, cumulative.
///
/// ⚠ Exists because `loss_ppm` alone is **ambiguous at zero**: a client receiving a flawless stream
/// and a client receiving *nothing at all* both report `loss_ppm = 0` — loss is a ratio over a
/// window whose denominator is the packets that arrived, so no-packets is indistinguishable from
/// no-loss. That ambiguity let a host decay adaptive FEC to its floor while the client sat behind a
/// black screen having received zero bytes, and the host's own stall diagnosis blamed the client for
/// "not sustaining the stream" it had never been sent (field 2026-08-20: a Windows host whose
/// per-session data port was closed inbound, so the client's hole-punch never opened the return
/// path). `0` while the host has sent frames is the one unambiguous statement of "the video data
/// plane is not reaching me" — the control plane carrying this report is, by construction, healthy.
///
/// ⚠ A SEPARATE MESSAGE rather than a field appended to [`LossReport`], and that is load-bearing:
/// `LossReport::decode` length-checks EXACTLY, so a longer report is rejected outright by every host
/// already shipped — a new client would silently lose adaptive FEC against them. Mixed versions are
/// normal here (the field case that motivated this ran a current host against a months-old client),
/// so the compatible shape is a new type byte an older host simply ignores, exactly as it already
/// ignores every other control message it predates.
///
/// Cumulative, not per-window, so a single message is self-contained; `u64` to match the counter it
/// mirrors, with no saturation to reason about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DeliveryReport {
pub packets_received: u64,
}
/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate
/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request
/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with
@@ -270,6 +297,8 @@ pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
/// and [`BitrateChanged`] already feed. Deliberately NOT in the 0x30 clock block — it carries a
/// duration precisely so that no clock domain is involved.
pub const MSG_PIPELINE_GAP: u8 = 0x0A;
/// Type byte of [`DeliveryReport`].
pub const MSG_DELIVERY_REPORT: u8 = 0x0B;
/// Type byte of [`ProbeRequest`].
pub const MSG_PROBE_REQUEST: u8 = 0x20;
/// Type byte of [`ProbeResult`].
@@ -436,6 +465,26 @@ impl LossReport {
}
}
impl DeliveryReport {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] packets_received[5..13]
let mut b = Vec::with_capacity(13);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_DELIVERY_REPORT);
b.extend_from_slice(&self.packets_received.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<DeliveryReport> {
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_DELIVERY_REPORT {
return Err(PunktfunkError::InvalidArg("bad DeliveryReport"));
}
Ok(DeliveryReport {
packets_received: u64::from_le_bytes(b[5..13].try_into().unwrap()),
})
}
}
impl SetBitrate {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] bitrate_kbps[5..9]
@@ -1291,6 +1340,41 @@ mod tests {
.is_err());
}
#[test]
fn delivery_report_roundtrip() {
for packets_received in [0u64, 1, 9_999, u32::MAX as u64 + 1, u64::MAX] {
let r = DeliveryReport { packets_received };
assert_eq!(DeliveryReport::decode(&r.encode()).unwrap(), r);
}
assert!(DeliveryReport::decode(&RequestKeyframe.encode()).is_err());
assert!(DeliveryReport::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
}
/// The delivery count MUST NOT ride on [`LossReport`]: that message is length-checked EXACTLY,
/// so lengthening it would make every already-shipped host reject the loss reports its adaptive
/// FEC runs on — a silent regression for a new client against an old host, which is the normal
/// mixed-version case here (the field report that motivated this ran a current host against a
/// months-old client). Its own type byte keeps `LossReport` byte-identical while an older host
/// simply ignores the message it does not know.
#[test]
fn the_delivery_count_does_not_disturb_the_loss_report_wire_form() {
let loss = LossReport { loss_ppm: 42 }.encode();
assert_eq!(loss.len(), 9, "LossReport must stay the 9-byte wire form");
assert_eq!(loss[4], MSG_LOSS_REPORT);
let delivery = DeliveryReport {
packets_received: 0,
}
.encode();
assert_ne!(
delivery[4], MSG_LOSS_REPORT,
"a distinct type byte is what makes an old host ignore it instead of failing"
);
// Neither can be silently mis-parsed as the other.
assert!(LossReport::decode(&delivery).is_err());
assert!(DeliveryReport::decode(&loss).is_err());
}
#[test]
fn window_loss_ppm_estimates_and_caps() {
// No traffic → 0. A clean window (nothing recovered) → 0.
+180 -36
View File
@@ -114,9 +114,18 @@ pub enum LeaseKind {
Child,
/// A launcher owns the game; it is recognized by its [`DetectSpec`].
Matched,
/// Nothing identifies this title's process — no detect signals and no child we own. Both
/// lifetime behaviors stay inert for it, and the host says so once in the log rather than
/// guessing.
/// A launcher owns the game and **tells us** when it starts and stops
/// ([`crate::runstate`]) — no process signal of our own.
///
/// The one lease kind whose liveness the host does not determine for itself, and the answer to
/// a title that has nothing to scan for: Playnite launches an emulated or manually-added game
/// through its own tracking and reports the edges, where the host could see only a
/// `playnite://` forwarder exiting. Before this such a title was [`Untracked`](Self::Untracked)
/// — the honest answer at the time, and a dead end.
Reported,
/// Nothing identifies this title's process — no detect signals, no child we own, and no
/// provider reporting on it. Both lifetime behaviors stay inert for it, and the host says so
/// once in the log rather than guessing.
Untracked,
}
@@ -126,6 +135,7 @@ impl LeaseKind {
Self::Nested => "nested",
Self::Child => "child",
Self::Matched => "matched",
Self::Reported => "reported",
Self::Untracked => "untracked",
}
}
@@ -387,6 +397,12 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
LeaseKind::Child
} else if !spec.is_empty() {
LeaseKind::Matched
} else if crate::runstate::speaks_for(game.id.as_deref()) {
// Nothing to scan for, but the provider that published this title is reporting liveness for
// it — so it is tracked after all. Asked once, here, rather than every poll: a lease's kind
// is what decides whether it is watched at all, and a title that flipped kind mid-flight
// would make both lifetime behaviors depend on a plugin's uptime.
LeaseKind::Reported
} else {
LeaseKind::Untracked
};
@@ -551,6 +567,27 @@ fn watch(
s.is_some_and(|p| !scanner.alive(&[p]).is_empty())
};
// What this title's provider says about it, when one reports at all ([`crate::runstate`]) —
// `None` on every host with no reporting plugin, which is what keeps all of this inert until
// someone opts in. Re-read each poll rather than captured: the whole value of it is that it
// changes while the lease is alive.
let reported = || shared.game.id.as_deref().and_then(crate::runstate::opinion);
// What a `Child` lease falls back to once its child turns out to be a shim: the store's own
// signals, else the provider's reporting, else nothing. The same ladder [`open`] walks, minus
// the child that has just gone away — and the reason a hint-less Playnite title is tracked at
// all on Windows, where the launch is `explorer.exe "playnite://…"` and therefore ALWAYS a
// hand-off, so every such lease arrives here.
let fallback_kind = || {
if !shared.spec.is_empty() {
LeaseKind::Matched
} else if crate::runstate::speaks_for(shared.game.id.as_deref()) {
LeaseKind::Reported
} else {
LeaseKind::Untracked
}
};
// ---- Phase 1: wait for the game to show up. ----
let start_deadline = spawned_at + START_GRACE;
loop {
@@ -567,8 +604,10 @@ fn watch(
&& !spawned_up(&spawned)
{
spawned = None;
if spawned_at.elapsed() < SHIM_WINDOW {
if shared.spec.is_empty() {
let quick = spawned_at.elapsed() < SHIM_WINDOW;
kind = fallback_kind();
if quick {
if matches!(kind, LeaseKind::Untracked) {
tracing::info!(
title = %shared.game.title,
"the launch command exited immediately (a launcher handing off) and this \
@@ -582,11 +621,10 @@ fn watch(
}
tracing::debug!(
title = %shared.game.title,
"the launch command handed off and exited — recognizing the game by its store \
signals instead"
kind = kind.as_str(),
"the launch command handed off and exited — recognizing the game another way"
);
kind = LeaseKind::Matched;
} else if shared.spec.is_empty() {
} else if matches!(kind, LeaseKind::Untracked) {
// It ran long enough to have BEEN the game, and nothing else identifies it.
shared.was_running.store(true, Ordering::Relaxed);
finish(&shared, &on_exit, "the launched process exited");
@@ -604,31 +642,30 @@ fn watch(
shared.forget_child();
if quick && status.success() {
// A launcher that handed the game off and exited. Fall back to recognizing
// the game by its store's signals; with none, stop tracking entirely rather
// than pretend the shim's exit was the game's.
kind = if shared.spec.is_empty() {
// the game by its store's signals (or its provider's reporting); with
// neither, stop tracking entirely rather than pretend the shim's exit was
// the game's.
kind = fallback_kind();
if matches!(kind, LeaseKind::Untracked) {
tracing::info!(
title = %shared.game.title,
"the launch command exited immediately (a launcher handing off) and \
this title has no detect signals stopping game tracking for it"
);
LeaseKind::Untracked
} else {
tracing::debug!(
title = %shared.game.title,
"the launch command handed off and exited — recognizing the game by \
its store signals instead"
);
LeaseKind::Matched
};
if matches!(kind, LeaseKind::Untracked) {
shared.set_state(GameState::Untracked);
return;
}
tracing::debug!(
title = %shared.game.title,
kind = kind.as_str(),
"the launch command handed off and exited — recognizing the game \
another way"
);
} else {
// It ran long enough to have BEEN the game (or failed outright). Either way
// the game is gone; only a success after a real run counts as "played".
if shared.spec.is_empty() {
kind = fallback_kind();
if matches!(kind, LeaseKind::Untracked) {
if spawned_at.elapsed() >= SHIM_WINDOW {
shared.was_running.store(true, Ordering::Relaxed);
finish(&shared, &on_exit, "the launched process exited");
@@ -642,11 +679,7 @@ fn watch(
Some(Err(e)) => {
tracing::debug!(error = %e, "could not poll the launched child — falling back to scanning");
child = None;
kind = if shared.spec.is_empty() {
LeaseKind::Untracked
} else {
LeaseKind::Matched
};
kind = fallback_kind();
if matches!(kind, LeaseKind::Untracked) {
shared.set_state(GameState::Untracked);
return;
@@ -680,7 +713,12 @@ fn watch(
&& (child.is_some() || spawned.is_some())
&& spawned_at.elapsed() >= SHIM_WINDOW;
let live = scanner.find(&shared.spec, shared.launch_stamp);
if !live.is_empty() || child_alive {
// A provider saying so is as good as seeing it — better, for a title there is nothing to
// see: it is the launcher that started the game telling us it did. This is the only way a
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
// gets there sooner than the scan would.
let said_running = reported().is_some_and(|l| l.running);
if !live.is_empty() || child_alive || said_running {
known = live.clone();
publish(&live);
shared.was_running.store(true, Ordering::Relaxed);
@@ -754,6 +792,27 @@ fn watch(
gone_since = None;
vetoed = false;
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
} else if let Some(said) = reported() {
// Nothing of the game is visible to us, but its provider is still reporting on it — and
// that report is decisive in BOTH directions, where `running_hint` below may only ever
// delay an exit.
//
// The difference is what backs each claim. Steam's registry flag is a leftover that
// survives an unclean exit, so believing it indefinitely produces a session that never
// ends; a provider report is an event from the launcher that started the game, restated
// continuously, and it stops counting the moment it goes stale
// ([`crate::runstate::REPORT_TTL`]) — after which this branch simply stops being taken
// and the scan-only path below resumes. So a *live* provider is allowed to hold the
// session open for a game the host cannot see at all, which is the entire point for a
// title with no detect signals, and a dead one costs at most one TTL.
if said.running {
gone_since = None;
vetoed = false;
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
} else {
finish(&shared, &on_exit, "its provider reported the game stopped");
return;
}
} else {
// How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by
// the veto below — letting it run on is exactly what bounds the veto.
@@ -909,7 +968,7 @@ fn terminate_blocking(shared: &LeaseShared) {
"released the nested session's kept display to end its game"
);
}
LeaseKind::Child | LeaseKind::Matched => {
LeaseKind::Child | LeaseKind::Matched | LeaseKind::Reported => {
#[cfg(target_os = "linux")]
unix_term_ladder(shared);
#[cfg(windows)]
@@ -919,6 +978,26 @@ fn terminate_blocking(shared: &LeaseShared) {
}
}
/// The process this lease's provider reports for its game, re-resolved and pinned to its start
/// time, or `None`.
///
/// The reason the wire carries a pid at all: for a [`LeaseKind::Reported`] title the matcher finds
/// nothing by construction, so without this "End" would have no target and would silently do
/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at
/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a
/// pid the kernel has since recycled, contributes nothing.
#[cfg(any(target_os = "linux", windows))]
fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
let pid = shared
.game
.id
.as_deref()
.and_then(crate::runstate::opinion)
.filter(|l| l.running)?
.pid?;
crate::procscan::resolve(pid)
}
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
///
/// Every pid is re-verified against its recorded start time immediately before each signal, so a pid
@@ -942,11 +1021,22 @@ fn unix_term_ladder(shared: &LeaseShared) {
// `OwnedChild::group_leader`) — never for a child sharing the host's own group.
unsafe { libc::kill(target, sig) == 0 }
};
// Everything the matcher can find, plus the pid the provider reported (see `reported_proc`) —
// which for a `Reported` lease is the only member of this set.
let targets = || {
let mut procs = scanner.find(&shared.spec, shared.launch_stamp);
if let Some(p) = reported_proc(shared) {
if !procs.iter().any(|q| q.pid == p.pid) {
procs.push(p);
}
}
procs
};
let signal_matched = |sig: i32| -> usize {
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
// sweep is never hit.
scanner
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
.alive(&targets())
.into_iter()
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
@@ -965,9 +1055,7 @@ fn unix_term_ladder(shared: &LeaseShared) {
let deadline = Instant::now() + TERM_GRACE;
while Instant::now() < deadline {
std::thread::sleep(POLL);
let still = scanner
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
.len();
let still = scanner.alive(&targets()).len();
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
let child_gone = !signal_child(0);
if still == 0 && child_gone {
@@ -1000,11 +1088,19 @@ fn windows_term_ladder(shared: &LeaseShared) {
let live = || {
let mut procs = scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
// Re-verified like everything else, so a dead or recycled pid contributes nothing, and
// de-duplicated: the matcher may well have found this same process by its image.
if let Some(p) = shared.spawned {
// de-duplicated: the matcher may well have found this same process by its image. The
// provider's reported pid joins on the same terms, and for a `Reported` lease it is the
// only thing here (see `reported_proc`).
let mut fold = |p: crate::procscan::ProcRef| {
if !scanner.alive(&[p]).is_empty() && !procs.iter().any(|q| q.pid == p.pid) {
procs.push(p);
}
};
if let Some(p) = shared.spawned {
fold(p);
}
if let Some(p) = reported_proc(shared) {
fold(p);
}
procs
};
@@ -1570,6 +1666,54 @@ mod tests {
assert!(!l.shared().is_trackable());
}
/// A title with nothing to scan for is tracked after all when its provider reports on it.
///
/// This is the Playnite case the static `detect` hints could never reach: an emulated game, a
/// manually added one, a library plugin that records no install directory. The launch is a
/// `playnite://` hand-off, so the host holds nothing; the spec is empty, so the matcher finds
/// nothing; and the honest verdict used to be [`LeaseKind::Untracked`] — no exit detection, and
/// `POST /game/end` with nothing to aim at. Playnite knew the whole time.
#[test]
fn a_reported_title_is_tracked_where_it_used_to_be_untracked() {
// The same request with no provider reporting: unchanged, and the control for what follows.
let l = open(
req("playnite:lease-test", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
assert!(!l.shared().is_trackable());
drop(l);
// A provider that speaks for the title — while reporting it NOT running, which is exactly
// what a report looks like at the moment a game is launched. Trackability follows from the
// provider *reporting*, not from what it currently says; a lease whose kind flipped with
// the answer would make both lifetime behaviours depend on a plugin's timing.
crate::runstate::report(
"playnite-lease-test",
["playnite:lease-test".to_string()].into_iter().collect(),
std::collections::HashMap::new(),
);
let l = open(
req("playnite:lease-test", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Reported));
assert!(
l.shared().is_trackable(),
"so its exit is noticed and `POST /game/end` has a target"
);
drop(l);
crate::runstate::forget("playnite-lease-test");
// …and once the provider is gone, so is the tracking. Pinned because a report that outlived
// its plugin is the one way this could hold a session open forever.
let l = open(
req("playnite:lease-test", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
}
#[test]
fn an_untracked_lease_is_never_terminated() {
let l = open(
+3
View File
@@ -105,6 +105,9 @@ mod plugins;
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
mod procscan;
// The live half of the same binding: what a provider PLUGIN reports about its titles' liveness,
// where `procscan` can only look at the process table.
mod runstate;
mod send_pacing;
#[cfg(target_os = "windows")]
#[path = "windows/service.rs"]
+1
View File
@@ -372,6 +372,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
library::reconcile_provider_entries,
library::delete_provider_entries
))
.routes(routes!(library::report_provider_running))
.routes(routes!(library::get_library_art))
.routes(routes!(stats::stats_capture_start))
.routes(routes!(stats::stats_capture_stop))
+4
View File
@@ -250,6 +250,10 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
(&Method::DELETE, "/api/v1/library/custom/{}"),
(&Method::PUT, "/api/v1/library/provider/{}"),
(&Method::DELETE, "/api/v1/library/provider/{}"),
// Liveness reporting for a provider's OWN titles. No new authority: the host maps the
// report through the catalog, so a plugin can only ever speak about entries it published,
// and the worst a defective one can do to someone else's session is nothing at all.
(&Method::PUT, "/api/v1/library/provider/{}/running"),
// Stats / telemetry.
(&Method::POST, "/api/v1/stats/capture/start"),
(&Method::POST, "/api/v1/stats/capture/stop"),
+118
View File
@@ -607,12 +607,130 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
if removed > 0 {
tracing::info!(provider, removed, "library provider entries removed");
}
// Its entries are gone, so its opinions about them are meaningless — and a lease must
// never be held open by a provider that no longer exists.
crate::runstate::forget(&provider);
Json(ProviderRemoved { removed }).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// One running title in a provider's liveness report.
#[derive(Deserialize, ToSchema)]
pub(crate) struct RunningTitle {
/// The provider's own stable id for the title — the same key its reconcile payload uses.
pub external_id: String,
/// The process id the provider started for it, when it knows one. Optional, and never trusted
/// as a bare number: the host re-resolves it and pins it to its start time before it is ever
/// signalled, so a stale or recycled pid simply contributes nothing.
#[serde(default)]
pub pid: Option<u32>,
}
/// Request body for `reportProviderRunning`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ProviderRunningInput {
/// Every title of this provider's that is running **right now**. The full set, not a delta:
/// anything absent from it is reported as stopped.
#[serde(default)]
pub running: Vec<RunningTitle>,
}
/// The result of a liveness report.
#[derive(Serialize, ToSchema)]
pub(crate) struct ProviderRunningAccepted {
/// How many reported titles matched an entry this provider currently publishes.
matched: usize,
/// How many were ignored because no such entry exists (a report that raced a reconcile).
unknown: usize,
/// Seconds this report stays authoritative without being restated — re-report inside it while
/// anything is running.
ttl_s: u64,
}
/// Report which of a provider's titles are running
///
/// The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to
/// recognize* a title's process, this one says *it is running now* (design §9,
/// [`crate::runstate`]). For a provider that starts games itself and knows when they stop —
/// Playnite tracks every launch and fires an event on both edges — this is a fact the host would
/// otherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated
/// game, a manually added one) could not derive at all.
///
/// Declarative and idempotent, like the reconcile: 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 rather than drifting.
///
/// The report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a
/// live provider to keep 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 process
/// scanning exactly as it does without one. Re-report on every change **and** on a timer well
/// inside the window.
///
/// Titles the provider does not currently publish are ignored (counted in `unknown`), not an error:
/// a report may legitimately race its own reconcile.
#[utoipa::path(
put,
path = "/library/provider/{provider}/running",
tag = "library",
operation_id = "reportProviderRunning",
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
request_body = ProviderRunningInput,
responses(
(status = OK, description = "The report was accepted", body = ProviderRunningAccepted),
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn report_provider_running(
Path(provider): Path<String>,
ApiJson(input): ApiJson<ProviderRunningInput>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
// Resolve the provider's own keys to the ids the rest of the host uses. A plugin knows its
// titles by `external_id`; a lease knows them by the library id the catalog assigned
// (`playnite:<guid>`), and only the catalog can map between the two — which is also what makes
// this authorization-safe, since a provider can only ever speak about entries it published.
let mine: Vec<(String, String)> = crate::library::load_custom()
.into_iter()
.filter(|e| e.provider.as_deref() == Some(provider.as_str()))
.filter_map(|e| {
let external = e.external_id.clone()?;
Some((external, crate::library::library_id_for(&e)))
})
.collect();
let owned: std::collections::HashSet<String> = mine.iter().map(|(_, id)| id.clone()).collect();
let mut running = std::collections::HashMap::new();
let mut unknown = 0usize;
for t in &input.running {
match mine.iter().find(|(external, _)| *external == t.external_id) {
Some((_, id)) => {
running.insert(id.clone(), t.pid);
}
None => unknown += 1,
}
}
let matched = running.len();
tracing::debug!(
provider,
owned = owned.len(),
matched,
unknown,
"provider liveness report"
);
crate::runstate::report(&provider, owned, running);
Json(ProviderRunningAccepted {
matched,
unknown,
ttl_s: crate::runstate::REPORT_TTL.as_secs(),
})
.into_response()
}
/// Fetch one cover-art image for a library entry
///
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
+61
View File
@@ -1440,6 +1440,16 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
("DELETE", "/api/v1/library/custom/{id}", true, false),
("PUT", "/api/v1/library/provider/{provider}", true, false),
("DELETE", "/api/v1/library/provider/{provider}", true, false),
// Liveness for a provider's own titles: the plugin lane's, like the reconcile beside it,
// and for the same reason — the host maps the report through the catalog, so a provider can
// only ever speak about entries it published. Never the cert lane: a streaming client has
// no titles of its own to report on.
(
"PUT",
"/api/v1/library/provider/{provider}/running",
true,
false,
),
// ---- stats.
("POST", "/api/v1/stats/capture/start", true, false),
("POST", "/api/v1/stats/capture/stop", true, false),
@@ -2935,3 +2945,54 @@ async fn provider_reconcile_validation() {
let (s, _) = send(&app, del).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
/// Liveness reporting: the provider id is validated like every other provider write, and a title
/// the provider does not publish is *counted*, not refused.
///
/// That tolerance is the point. A report races its own reconcile by construction — a game can start
/// before the entry that describes it has landed — and 400-ing the whole report over one unknown id
/// would throw away the liveness of every other running title, which is precisely the failure the
/// launcher-tile 400 taught us to avoid (`sanitize_launcher_entries`). The developer's real catalog
/// is not touched here, so every id in this test is `unknown` by construction — which is exactly
/// the case being pinned.
#[tokio::test]
async fn provider_running_report_validation() {
let app = test_app(test_state(), None);
let put = |provider: &str, body: serde_json::Value| {
axum::http::Request::put(format!("/api/v1/library/provider/{provider}/running"))
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
let (s, json) = send(&app, put("manual", serde_json::json!({"running": []}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(json["error"].as_str().unwrap().contains("reserved"));
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!({"running": []}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// An unreported provider is a legitimate report of "nothing is running".
let (s, json) = send(&app, put("playnite", serde_json::json!({"running": []}))).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["matched"], 0);
assert_eq!(json["unknown"], 0);
assert!(json["ttl_s"].as_u64().unwrap() > 0);
// An id this provider does not publish is ignored, not an error.
let (s, json) = send(
&app,
put(
"playnite",
serde_json::json!({"running": [{"external_id": "no-such-title", "pid": 4242}]}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["matched"], 0);
assert_eq!(json["unknown"], 1);
// A report leaves no opinion behind about a title nobody published, so nothing this test did
// can hold a real lease open.
assert!(!crate::runstate::speaks_for(Some("playnite:no-such-title")));
crate::runstate::forget("playnite");
}
+28
View File
@@ -1404,6 +1404,12 @@ async fn serve_session(
// evidence (a refusal without the score left a 23-minute floor-pinned field session with no
// trace of why).
let cadence_behind_score = Arc::new(AtomicU32::new(0));
// Delivery truth, control task → data plane: the packet count the client reports having
// received all session (`u32::MAX` until a client new enough to answer sends one). The data
// plane needs it to tell a clean link from a dead one — `loss_ppm = 0` means both — before it
// blames the client for a stream that never reached it.
let client_packets_received = Arc::new(AtomicU32::new(u32::MAX));
let client_packets_received_ctl = client_packets_received.clone();
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
@@ -1535,6 +1541,7 @@ async fn serve_session(
encoder_ceiling_kbps.clone(),
cadence_degraded.clone(),
cadence_behind_score.clone(),
client_packets_received_ctl,
fec_target_ctl,
phase_ctl_control,
reconfig_tx,
@@ -2093,6 +2100,26 @@ async fn serve_session(
address with no hole-punch; else punched=true the client's observed source, \
false no punch seen, the reported address)"
);
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
// data port the client cannot reach INBOUND, and every client punches (5/s for the
// first three seconds, then every two). Video then goes to an address the client only
// CLAIMED, unverified, and if anything on the path needed the flow opened client-first
// it silently goes nowhere: black picture, healthy control plane, no error anywhere.
// On Windows the usual cause is a firewall rule that opens fixed ports only, while
// this port is ephemeral and different every session (fixed by the program-scoped rule
// `service install` now adds — an install predating it still has the old rules).
// `direct` skips the punch by operator choice, so it is not a failure there.
if !direct && !punched {
tracing::warn!(
%client_udp,
udp_port,
"no hole-punch reached this host's data port — inbound UDP to it looks \
BLOCKED, so video is being sent to the address the client reported without \
any confirmed return path. If the picture stays black while the session is \
otherwise healthy, this line is the reason: allow inbound UDP for the host \
executable (any port), or pin --data-port and open that one"
);
}
let mut session = Session::new(cfg, Box::new(transport))
.map_err(|e| anyhow!("host session: {e:?}"))?;
match source {
@@ -2127,6 +2154,7 @@ async fn serve_session(
encoder_ceiling_kbps,
cadence_degraded,
cadence_behind_score,
client_packets_received,
bitrate_auto,
bit_depth,
chroma,
@@ -30,6 +30,10 @@ pub(super) async fn run(
encoder_ceiling_kbps: Arc<AtomicU32>,
cadence_degraded: Arc<AtomicBool>,
cadence_behind_score: Arc<AtomicU32>,
// Delivery truth, published from every `DeliveryReport` for the data plane's stall diagnosis:
// the packets the client says it has received all session (`u32::MAX` = a client too old to
// send one, the pre-seeded value).
client_packets_received: Arc<AtomicU32>,
fec_target_ctl: Arc<AtomicU8>,
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
@@ -162,6 +166,16 @@ pub(super) async fn run(
if rfi_tx.send((req.first_frame, req.last_frame)).is_err() {
break; // data plane gone
}
} else if let Ok(rep) = punktfunk_core::quic::DeliveryReport::decode(&msg) {
// What the client has actually RECEIVED — published unconditionally, because it
// is what lets the data plane read `loss_ppm = 0` correctly and must survive
// both the `adaptive_fec` opt-out and a pinned FEC percentage (a host with
// PUNKTFUNK_FEC_PCT set is exactly as blind to a dead data plane otherwise).
// Saturated into the u32 bridge; the value only ever matters near zero.
client_packets_received.store(
rep.packets_received.min(u32::MAX as u64 - 1) as u32,
Ordering::Relaxed,
);
} else if let Ok(rep) = LossReport::decode(&msg) {
// Adaptive FEC: size recovery to the loss the client is seeing. The data-plane
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
+109 -8
View File
@@ -1319,6 +1319,14 @@ pub(super) struct SessionContext {
/// of what held it there — the score is the missing discriminator between "the detector's
/// budget is wrong" and "this encoder genuinely can't hold cadence").
pub(super) cadence_behind_score: Arc<AtomicU32>,
/// Data-plane packets the CLIENT says it has received all session, from the latest
/// [`punktfunk_core::quic::DeliveryReport`] ([`u32::MAX`] = a client too old to send one).
///
/// The one signal that distinguishes "the link is clean" from "nothing is arriving": both look
/// like `loss_ppm = 0`, because loss is a ratio over the packets that DID arrive. Read by the
/// keyframe-cadence diagnosis below, which without it accuses the client of being too slow for
/// a stream it has never received a byte of.
pub(super) client_packets_received: Arc<AtomicU32>,
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
@@ -1598,6 +1606,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
encoder_ceiling_kbps,
cadence_degraded,
cadence_behind_score,
client_packets_received,
bitrate_auto,
bit_depth,
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
@@ -3006,16 +3015,65 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// subsystems while the real chain was: client refused the codec → demoted to
// a slower decode rung → could not sustain the rate → standing queue.
// Perfect periodicity argues FOR a software cooldown, not against it.
if matches_client_flush_cadence(period) {
tracing::warn!(
let client_rx = client_packets_received.load(Ordering::Relaxed);
// The client has TOLD us it has received nothing all session (a v1 client
// leaves the `u32::MAX` seed, so this only fires on an explicit zero). That
// outranks both cadence verdicts below, which are about a client drowning in
// frames — the opposite failure, and indistinguishable by period alone because
// a client that got no picture re-asks on its own no-video timer at very
// nearly the same spacing. Diagnosing this as "too slow" cost a 2026-08-20
// field investigation days: the host was blameless-looking (`sent` climbing,
// `loss_ppm = 0`, FEC decayed to the floor) while not one byte of video ever
// reached the client.
if client_rx == 0 {
tracing::error!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries match the client's jump-to-live cooldown \
the CLIENT cannot sustain the stream and is shedding a standing \
receive queue (check its log for 'receive backlog stopped draining' \
with queue_depth, and for a decode rung that demoted); a slower \
decode path or a link below the bitrate does this, and it is NOT a \
host display disturbance"
frames_sent = sent,
"THE VIDEO DATA PLANE IS NOT REACHING THE CLIENT — it reports 0 \
packets received all session while this host has sent the frames \
counted here, so the picture is black and every keyframe we force is \
wasted. The control plane is healthy (this report arrived on it), so \
the session looks alive: audio, input and the library keep working. \
This is a PATH problem, not decode check that inbound UDP to this \
host's per-session data port is allowed (the 'data plane bound' line \
above shows `punched=false` when the client's hole-punch never \
arrived, which is the fingerprint), and that no other host or \
firewall is intercepting it"
);
} else if matches_client_recovery_cooldown(period) {
if client_rx == u32::MAX {
// This client predates the delivery count, so the period alone has to
// carry the verdict — and it CANNOT: both client cooldowns live in this
// band and they mean opposite things. Say so instead of picking one.
// The old confident wording sent a field investigation after the
// decoder for days while the real fault was that nothing arrived.
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
frames_sent = sent,
"client keyframe recoveries land on a client software cooldown, \
but this client is too old to report whether any video reached \
it so this is EITHER a client that cannot sustain the stream \
and is shedding a standing receive queue, OR a client that has \
received nothing at all and is re-asking on its no-video timer. \
They are opposite faults; the host cannot tell them apart from \
the period. Its log does: 'receive backlog stopped draining' \
(with queue_depth) means the first, 'no video received into \
the session' means the second. Upgrading the client makes this \
line decide on its own"
);
} else {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
client_packets_received = client_rx,
"client keyframe recoveries match the client's jump-to-live \
cooldown, and it confirms video IS arriving the CLIENT cannot \
sustain the stream and is shedding a standing receive queue \
(check its log for 'receive backlog stopped draining' with \
queue_depth, and for a decode rung that demoted); a slower \
decode path or a link below the bitrate does this, and it is NOT \
a host display disturbance"
);
}
} else {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
@@ -4191,6 +4249,26 @@ fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
period.abs_diff(flush) < flush / 10
}
/// The client's OTHER re-ask cooldown: it has received no video whatsoever and is asking for a
/// keyframe on its no-video timer. Kept separate from [`matches_client_flush_cadence`] because the
/// two describe opposite faults — drowning in frames versus receiving none — and only the client's
/// reported delivery count can say which. Both are host-side-irrelevant either way: a fixed
/// software cooldown is never the periodic *disturbance* the metronomic branch reports.
///
/// Compared against the SHARED constant, never a copy of the number — the same discipline
/// [`matches_client_flush_cadence`] follows, and the one that was missing when the two cooldowns
/// were both 2000 ms and the host could not even tell that it was guessing.
fn matches_client_no_video_cadence(period: std::time::Duration) -> bool {
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
period.abs_diff(no_video) < no_video / 10
}
/// Either client cooldown — the band in which a period tells us about the CLIENT's software, not
/// about anything physical on this host.
fn matches_client_recovery_cooldown(period: std::time::Duration) -> bool {
matches_client_flush_cadence(period) || matches_client_no_video_cadence(period)
}
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
type Pipeline = (
@@ -5068,6 +5146,29 @@ mod tests {
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
}
/// The two client cooldowns must stay TELLABLE APART by period, and both must stay out of the
/// display-disturbance branch. While they were both 2000 ms a black-screen field case (nothing
/// ever reached the client) was reported as "the client cannot sustain the stream" — the exact
/// opposite fault — because the periods were identical and the host guessed.
#[test]
fn the_two_client_cooldowns_are_distinguishable_and_both_excluded_from_display_blame() {
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
let no_video = punktfunk_core::client::NO_VIDEO_RETRY;
assert_ne!(
flush, no_video,
"identical cooldowns make the host's verdict a coin flip"
);
// Neither may fall inside the other's ±10% band, or the period stops discriminating.
assert!(!matches_client_flush_cadence(no_video));
assert!(!matches_client_no_video_cadence(flush));
// Both are client software cooldowns: never the metronomic display-disturbance branch.
assert!(matches_client_recovery_cooldown(flush));
assert!(matches_client_recovery_cooldown(no_video));
// A real periodic disturbance still reaches that branch.
assert!(!matches_client_recovery_cooldown(flush * 3));
assert!(!matches_client_recovery_cooldown(std::time::Duration::ZERO));
}
#[test]
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
const DEGRADE: u32 = 10;
+217
View File
@@ -0,0 +1,217 @@
//! What a provider plugin **says** is running — the one liveness signal the host cannot work out
//! for itself.
//!
//! [`crate::procscan`] answers "is this game running" by looking at the process table, and
//! [`crate::gamelease`] turns that into a session lifetime. That works because most stores leave
//! something recognizable behind: an install directory, an executable, a Steam reaper. Some do not,
//! and one store in particular *already knows the answer*: Playnite starts the game itself, tracks
//! it with the mode the person configured (process, directory, original-process), and fires an
//! event on both edges — carrying the pid it started. Every bit of that was being thrown away, and
//! the host was left re-deriving a worse version of it by scanning.
//!
//! So this is the inbound half of [`crate::library::DetectHint`]. That one is *static* ("here is
//! how to recognize my title's process"); this one is *live* ("that title is running right now, and
//! here is its pid"). A provider PUTs its full running set; the host keeps it here; the lease
//! watcher consults it.
//!
//! ### Why the whole set, and why a TTL
//!
//! The wire is declarative — the same shape as the library reconcile, for the same reason. A
//! provider that missed an event, restarted, or was installed mid-game converges on its next PUT
//! instead of drifting forever; there is no per-event delta to lose.
//!
//! And a report **expires**. A plugin that dies with a game running would otherwise leave a claim
//! that is true today and a lie tomorrow — and unlike Steam's registry flag (which
//! [`crate::procscan::running_hint`] must treat as merely a bounded veto because Steam leaves it
//! set on any unclean exit) this claim is allowed to *keep a session alive on its own*. That is
//! only safe while something is actively restating it, so a report older than [`REPORT_TTL`] stops
//! counting and the host falls back to scanning, exactly as it does today. The provider's side of
//! that bargain is to re-PUT well inside the window while anything is running.
use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
/// How long a provider's report stays authoritative without being restated.
///
/// Generous enough that a plugin refreshing every 30s survives a slow reconcile or a paused runner,
/// short enough that a *dead* plugin stops vetoing a session end within a couple of minutes. The
/// cost of expiring too early is the pre-existing behaviour (scan-only); the cost of never expiring
/// is a session that can never end on its own, which is the bug this whole area exists to kill.
pub const REPORT_TTL: Duration = Duration::from_secs(90);
/// What a provider says about one of its titles.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Liveness {
/// Whether the provider lists this title as running right now.
pub running: bool,
/// The pid the provider started for it, when it knows one. Never trusted as a bare number —
/// every use re-verifies it through [`crate::procscan`], which pins it to its start time.
pub pid: Option<u32>,
}
/// One provider's most recent report.
struct Report {
/// When it landed — the TTL clock.
at: Instant,
/// Every library id this provider speaks for. What makes "not in `running`" mean *not running*
/// rather than *no opinion*: without it an omitted title is indistinguishable from a title
/// belonging to some other provider entirely.
owned: HashSet<String>,
/// The subset that is running, each with the pid the provider started (when it has one).
running: HashMap<String, Option<u32>>,
}
impl Report {
fn fresh(&self) -> bool {
self.at.elapsed() < REPORT_TTL
}
}
fn table() -> MutexGuard<'static, HashMap<String, Report>> {
static TABLE: OnceLock<Mutex<HashMap<String, Report>>> = OnceLock::new();
TABLE
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
/// Record a provider's report, replacing whatever it said before.
///
/// `owned` is every library id the provider currently publishes; `running` is the subset that is
/// running, keyed the same way, valued by pid where one is known.
pub fn report(provider: &str, owned: HashSet<String>, running: HashMap<String, Option<u32>>) {
table().insert(
provider.to_string(),
Report {
at: Instant::now(),
owned,
running,
},
);
}
/// Forget everything a provider said — its entries are gone, so its opinions are meaningless.
pub fn forget(provider: &str) {
table().remove(provider);
}
/// What a *fresh* provider says about this library id, or `None` when none speaks for it.
///
/// `None` is the answer for every title on a host with no reporting plugin, which is what keeps
/// this entirely inert until someone opts in.
pub fn opinion(app_id: &str) -> Option<Liveness> {
let table = table();
table
.values()
.filter(|r| r.fresh())
.find(|r| r.owned.contains(app_id))
.map(|r| match r.running.get(app_id) {
Some(pid) => Liveness {
running: true,
pid: *pid,
},
None => Liveness {
running: false,
pid: None,
},
})
}
/// Whether any fresh provider reports liveness for this title at all — regardless of what it
/// currently says.
///
/// Asked once, when a lease opens: a title whose provider will tell us when it stops is trackable
/// even with no detect signals whatsoever, which is the whole point (see
/// [`crate::gamelease::LeaseKind::Reported`]).
pub fn speaks_for(app_id: Option<&str>) -> bool {
app_id.is_some_and(|id| opinion(id).is_some())
}
#[cfg(test)]
mod tests {
use super::*;
fn owned(ids: &[&str]) -> HashSet<String> {
ids.iter().map(|s| (*s).to_string()).collect()
}
fn running(ids: &[(&str, Option<u32>)]) -> HashMap<String, Option<u32>> {
ids.iter().map(|(s, p)| ((*s).to_string(), *p)).collect()
}
// The table is process-global and these tests run in parallel, so each takes a provider id and
// app ids only it uses, and cleans up only its own row. An earlier draft shared the id
// `playnite` and cleared the whole table between cases, which made the three of them flip each
// other's answers depending on scheduling — the same shape as `mgmt`'s `local_summary` race.
/// The three answers, and the distinction the whole module turns on: a title its provider omits
/// is *not running*, while a title nobody speaks for has *no opinion*. Conflating them would
/// make every unreported game on the box look like it had just quit.
#[test]
fn omitted_is_not_running_but_unknown_is_no_opinion() {
report(
"answers-test",
owned(&["answers:a", "answers:b"]),
running(&[("answers:a", Some(4242))]),
);
assert_eq!(
opinion("answers:a"),
Some(Liveness {
running: true,
pid: Some(4242)
})
);
assert_eq!(
opinion("answers:b"),
Some(Liveness {
running: false,
pid: None
})
);
assert_eq!(opinion("answers:never-published"), None);
assert!(speaks_for(Some("answers:b")));
assert!(!speaks_for(Some("answers:never-published")));
assert!(!speaks_for(None));
forget("answers-test");
}
/// A report replaces its predecessor wholesale. The set is the message: a title that dropped out
/// of it has stopped, and carrying the old entry forward would be exactly the stuck-running
/// state this exists to prevent.
#[test]
fn a_report_replaces_the_previous_one() {
report(
"replace-test",
owned(&["replace:a"]),
running(&[("replace:a", None)]),
);
report("replace-test", owned(&["replace:a"]), running(&[]));
assert_eq!(
opinion("replace:a"),
Some(Liveness {
running: false,
pid: None
})
);
forget("replace-test");
assert_eq!(opinion("replace:a"), None);
}
/// A stale report stops counting — the bound that makes it safe to let a plugin's claim hold a
/// session open. Seeded with an aged timestamp rather than by sleeping for 90 seconds.
#[test]
fn a_stale_report_has_no_opinion() {
table().insert(
"stale-test".to_string(),
Report {
at: Instant::now() - REPORT_TTL - Duration::from_secs(1),
owned: owned(&["stale:a"]),
running: running(&[("stale:a", Some(7))]),
},
);
assert_eq!(opinion("stale:a"), None);
assert!(!speaks_for(Some("stale:a")));
forget("stale-test");
}
}
@@ -1587,6 +1587,7 @@ fn add_firewall_rules(allow_public: bool) {
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
}
}
add_data_plane_firewall_rule(profile);
if !allow_public {
println!(
"Note: streaming ports are open on Private/Domain networks only. On a network Windows \
@@ -1596,7 +1597,75 @@ fn add_firewall_rules(allow_public: bool) {
}
}
/// Rule name for the program-scoped data-plane rule (see [`add_data_plane_firewall_rule`]).
const FW_DATA_PLANE_RULE: &str = "Punktfunk UDP (data plane)";
/// Inbound UDP for the host executable itself, at **any** local port.
///
/// The media data plane binds an EPHEMERAL port per session (`0.0.0.0:0`, reported to the client in
/// the Welcome), so no `localport=` rule can cover it — the port-scoped rules above open the fixed
/// control/GameStream/mDNS ports and nothing else. Without this, Windows Firewall drops the client's
/// hole-punch (`PUNCH_MAGIC` → the host's data port) on EVERY session: that is what `punched=false`
/// on the host's "data plane bound" line means. The punch then never opens the return path, video
/// falls back to blind-sending at the address the client merely *reported*, and the moment anything
/// on the path needs the flow opened client-first the stream goes black while the control plane
/// stays healthy — no reconnect, no error, just a session that never shows a picture.
///
/// Program-scoped rather than a pinned port: it covers whatever port the session picks, needs no
/// second rule when the range moves, and cannot collide with another host (a pinned data port in
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). The port rules above are kept as
/// they are — an install whose recorded exe path later moves still has its fixed ports open.
fn add_data_plane_firewall_rule(profile: &str) {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!(
"warning: could not resolve the host executable path ({e}) — skipping the \
data-plane firewall rule; streams may show a black picture behind a healthy \
connection on networks that need the client's hole-punch to open the path"
);
return;
}
};
let ok = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={FW_DATA_PLANE_RULE}"),
"dir=in",
"action=allow",
"protocol=UDP",
&format!("program={}", exe.to_string_lossy()),
profile,
],
);
if ok {
println!(
"Firewall rule added: {FW_DATA_PLANE_RULE} (any UDP port for {}) [{profile}]",
exe.display()
);
} else {
eprintln!(
"warning: could not add firewall rule '{FW_DATA_PLANE_RULE}' — the per-session video \
data port stays closed to inbound, so the client's hole-punch cannot reach it"
);
}
}
fn remove_firewall_rules() {
let _ = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={FW_DATA_PLANE_RULE}"),
],
);
for suffix in ["TCP", "UDP"] {
// Capital P is the brand; netsh matches a rule name case-INSENSITIVELY, so this still
// reaps the lowercase rules every release up to 0.22.1 created — no orphans on upgrade.
+125
View File
@@ -1860,6 +1860,69 @@
}
}
},
"/api/v1/library/provider/{provider}/running": {
"put": {
"tags": [
"library"
],
"summary": "Report which of a provider's titles are running",
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
"operationId": "reportProviderRunning",
"parameters": [
{
"name": "provider",
"in": "path",
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The report was accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningAccepted"
}
}
}
},
"400": {
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/scanners": {
"get": {
"tags": [
@@ -7792,6 +7855,46 @@
}
}
},
"ProviderRunningAccepted": {
"type": "object",
"description": "The result of a liveness report.",
"required": [
"matched",
"unknown",
"ttl_s"
],
"properties": {
"matched": {
"type": "integer",
"description": "How many reported titles matched an entry this provider currently publishes.",
"minimum": 0
},
"ttl_s": {
"type": "integer",
"format": "int64",
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
"minimum": 0
},
"unknown": {
"type": "integer",
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
"minimum": 0
}
}
},
"ProviderRunningInput": {
"type": "object",
"description": "Request body for `reportProviderRunning`.",
"properties": {
"running": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunningTitle"
},
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
@@ -7846,6 +7949,28 @@
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
"required": [
"external_id"
],
"properties": {
"external_id": {
"type": "string",
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
},
"pid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
"minimum": 0
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
+5
View File
@@ -1224,6 +1224,11 @@
#define PUNKTFUNK_MSG_PIPELINE_GAP 10
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`DeliveryReport`].
#define PUNKTFUNK_MSG_DELIVERY_REPORT 11
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ProbeRequest`].
#define PUNKTFUNK_MSG_PROBE_REQUEST 32
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.4.3",
"version": "0.4.4",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
+3
View File
@@ -22,6 +22,7 @@ export {
} from "./paths.js";
export {
Artwork,
DEFAULT_RUNNING_TTL_S,
DetectHint,
GameMeta,
LaunchSpec,
@@ -29,6 +30,8 @@ export {
ProviderClient,
type ProviderClientService,
ProviderEntry,
type RunningAccepted,
type RunningTitle,
} from "./reconcile.js";
export {
definePluginKit,
+82
View File
@@ -9,6 +9,14 @@ import type { ProviderEntry } from "./wire.js";
export * from "./wire.js";
/**
* The host's liveness-report TTL, in seconds, when it does not say.
*
* Only a fallback for parsing an unexpected answer the authority is the `ttlS` the host returns.
* A reporter should refresh at a fraction of this, so one missed call is not a lapse.
*/
export const DEFAULT_RUNNING_TTL_S = 90;
/** What the host echoed back for one reconciled entry — enough to tell whether a claim took. */
export interface ReconciledEntry {
readonly id: string;
@@ -35,6 +43,36 @@ export interface ProviderClientService {
entries: ReadonlyArray<ProviderEntry>,
store?: string,
) => Effect.Effect<ReadonlyArray<ReconciledEntry>, HostRequestError>;
/**
* Report which of this provider's titles are running **right now** the live counterpart to the
* static `detect` hints in {@link reconcile}.
*
* `detect` says *how to recognize* a title's process; this says *it is running*, and carries the
* pid where the provider knows one. For a launcher that starts games itself and is told when
* they stop, this is a fact the host would otherwise re-derive by scanning and for a title
* with nothing to scan for (an emulated game, a manually added one, a launcher that records no
* install directory) could not derive at all: its lease is `untracked`, its exit is never
* noticed, and the streaming session outlives the game.
*
* **Send the complete set, not a delta.** Anything absent is reported stopped, so a missed
* event, a plugin restart or an install mid-game all self-correct on the next call.
*
* **The host expires a report** (`ttlS` in the answer, 90s at the time of writing) unless it is
* restated which is what makes it safe for the host to keep a session open for a game it
* cannot see. Call this on every change **and** on a timer well inside that window while
* anything is running; a plugin that stops reporting simply hands tracking back to the host's
* process scan.
*
* Titles the host has no entry for are counted in `unknown`, not refused: a report may
* legitimately race its own reconcile.
*
* Fails on a host that predates the route (404) treat that as "this host tracks games by
* scanning" and carry on, exactly as with any other optional capability.
*/
readonly reportRunning: (
providerId: string,
running: ReadonlyArray<RunningTitle>,
) => Effect.Effect<RunningAccepted, HostRequestError>;
/**
* Remove every entry this provider owns **and release its store claim** (the explicit-uninstall
* path). Releasing is what brings the host's built-in scanner back.
@@ -44,6 +82,29 @@ export interface ProviderClientService {
) => Effect.Effect<void, HostRequestError>;
}
/** One running title in a {@link ProviderClientService.reportRunning} call. */
export interface RunningTitle {
/** The provider's own stable id — the same key its reconcile payload uses. */
readonly external_id: string;
/**
* The process the provider started for it, when it knows one. Optional, and never trusted as a
* bare number: the host re-resolves it and pins it to its start time before it is ever
* signalled, so a stale or recycled pid contributes nothing. Worth sending anyway it is what
* gives "End game" something to aim at for a title the host's matcher cannot find.
*/
readonly pid?: number;
}
/** What the host answered to a liveness report. */
export interface RunningAccepted {
/** How many reported titles matched an entry this provider currently publishes. */
readonly matched: number;
/** How many were ignored because no such entry exists (a report that raced a reconcile). */
readonly unknown: number;
/** Seconds the report stays authoritative without being restated. */
readonly ttlS: number;
}
export class ProviderClient extends Context.Service<
ProviderClient,
ProviderClientService
@@ -72,6 +133,27 @@ export class ProviderClient extends Context.Service<
: [],
),
),
reportRunning: (providerId, running) =>
host
.request("PUT", `/library/provider/${providerId}/running`, {
running,
})
.pipe(
// Same posture as the reconcile echo above: the counts are a
// diagnostic, not a contract, so a host that answers something
// unexpected must not fail a plugin's report loop. The TTL falls
// back to the host's own documented default.
Effect.map((body) => {
const b = (body ?? {}) as Record<string, unknown>;
const num = (v: unknown, fallback: number) =>
typeof v === "number" && Number.isFinite(v) ? v : fallback;
return {
matched: num(b.matched, 0),
unknown: num(b.unknown, 0),
ttlS: num(b.ttl_s, DEFAULT_RUNNING_TTL_S),
} satisfies RunningAccepted;
}),
),
remove: (providerId) =>
host
.request("DELETE", `/library/provider/${providerId}`)
@@ -180,6 +180,7 @@ PUNKTFUNK_MSG_CLOCK_ECHO
PUNKTFUNK_MSG_CLOCK_PROBE
PUNKTFUNK_MSG_CURSOR_RENDER
PUNKTFUNK_MSG_CURSOR_SHAPE
PUNKTFUNK_MSG_DELIVERY_REPORT
PUNKTFUNK_MSG_LOSS_REPORT
PUNKTFUNK_MSG_PAIR_CHALLENGE
PUNKTFUNK_MSG_PAIR_PROOF