Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a76287d8 | ||
|
|
cbd3d02817 | ||
|
|
2be444b329 | ||
|
|
669a1bc0ce | ||
|
|
8ff6fe6093 | ||
|
|
1758266bda | ||
|
|
d5fb1e4479 | ||
|
|
ea3c9e1202 | ||
|
|
685c4bd99a | ||
|
|
1280f697be |
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user