Merge remote-tracking branch 'origin/main' into worktree-haptics-m2-pad-slots
ci / docs-site (pull_request) Successful in 3m0s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m42s
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Failing after 11m17s
ci / rust (pull_request) Successful in 7m36s
ci / docs-site (pull_request) Successful in 3m0s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m42s
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Failing after 11m17s
ci / rust (pull_request) Successful in 7m36s
This commit is contained in:
+90
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.22.3"
|
||||
"version": "0.23.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -2170,6 +2170,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/plugins/logs": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"plugins"
|
||||
],
|
||||
"summary": "Ingest runner log lines",
|
||||
"description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.",
|
||||
"operationId": "ingestPluginLogs",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PluginLogBatch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Lines ingested"
|
||||
},
|
||||
"400": {
|
||||
"description": "Batch too large",
|
||||
"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/plugins/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -6238,6 +6283,50 @@
|
||||
"gamestream"
|
||||
]
|
||||
},
|
||||
"PluginLogBatch": {
|
||||
"type": "object",
|
||||
"description": "A batch of runner log lines.",
|
||||
"required": [
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginLogLine"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginLogLine": {
|
||||
"type": "object",
|
||||
"description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).",
|
||||
"required": [
|
||||
"ts_ms",
|
||||
"level",
|
||||
"source",
|
||||
"msg"
|
||||
],
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string",
|
||||
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`."
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:<source>`."
|
||||
},
|
||||
"ts_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"PluginRegistration": {
|
||||
"type": "object",
|
||||
"description": "Register/renew body for `PUT /plugins/{id}`.",
|
||||
|
||||
+10
-3
@@ -24,8 +24,15 @@ the panel looks and feels native to Gaming Mode.
|
||||
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
|
||||
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
|
||||
(matched by certificate fingerprint).
|
||||
5. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
|
||||
to the client's config.
|
||||
5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's
|
||||
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
|
||||
scrolling. The categories and their order are the console settings screen's — Stream (resolution
|
||||
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
|
||||
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
|
||||
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
|
||||
library / fullscreen). The device pickers are populated
|
||||
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
|
||||
there is more than one adapter.
|
||||
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
|
||||
a force-stop for a wedged stream client.
|
||||
|
||||
@@ -93,7 +100,7 @@ restart is required for an out-of-band install to appear.
|
||||
| --- | --- |
|
||||
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
|
||||
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
|
||||
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
|
||||
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
|
||||
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
|
||||
|
||||
+150
-3
@@ -21,6 +21,10 @@ The backend's jobs are the things Steam can't do:
|
||||
the frontend so it can create/point the Steam shortcut.
|
||||
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
|
||||
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
|
||||
``set_settings`` MERGES onto the file: it is shared with the desktop client and the console.
|
||||
* **list_devices() / refresh_devices()** — the GPUs and audio endpoints the settings tab's
|
||||
device pickers offer, read from the session binary (``--list-adapters`` / ``--list-audio``)
|
||||
and cached, since enumerating them costs a Vulkan + PipeWire init.
|
||||
* **kill_stream()** — force-stop a wedged stream (``flatpak kill``).
|
||||
* **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's
|
||||
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
|
||||
@@ -343,6 +347,9 @@ def _flatpak() -> str | None:
|
||||
# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real
|
||||
# home), so nothing else in this file has to care which one answered.
|
||||
NATIVE_BIN = "punktfunk-client"
|
||||
# The Vulkan session binary the shell execs to stream — and the only thing that can enumerate
|
||||
# this device's GPUs and audio endpoints for the settings pickers.
|
||||
SESSION_BIN = "punktfunk-session"
|
||||
|
||||
# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and
|
||||
# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix.
|
||||
@@ -398,6 +405,25 @@ def _client_argv() -> list[str] | None:
|
||||
return [native] if native else None
|
||||
|
||||
|
||||
def _session_argv() -> list[str] | None:
|
||||
"""The argv PREFIX that runs the SESSION binary headlessly, or None when it isn't there.
|
||||
|
||||
The device enumerations the settings pickers need (`--list-adapters`, `--list-audio`) live on
|
||||
`punktfunk-session`, not on the client: the GTK shell deliberately links no Vulkan itself and
|
||||
shells out to the session for exactly the same two lists (clients/linux/src/app.rs). The
|
||||
flatpak installs both binaries into /app/bin, so `--command=` picks the other one; a native
|
||||
install puts them in the same bindir, so the session is the client's sibling.
|
||||
"""
|
||||
prefix = _client_argv()
|
||||
if not prefix:
|
||||
return None
|
||||
if prefix[0] == _flatpak():
|
||||
# `flatpak run --command=<bin> <app>` — the app id must stay LAST.
|
||||
return [*prefix[:-1], f"--command={SESSION_BIN}", prefix[-1]]
|
||||
sibling = Path(prefix[0]).with_name(SESSION_BIN)
|
||||
return [str(sibling)] if sibling.exists() else None
|
||||
|
||||
|
||||
def _client_is_flatpak() -> bool:
|
||||
"""Is the client this plugin actually drives the FLATPAK one?
|
||||
|
||||
@@ -511,6 +537,63 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
|
||||
return -1, "", ""
|
||||
|
||||
|
||||
def _parse_audio_endpoints(out: str) -> tuple[list[dict], list[dict]]:
|
||||
"""Split `punktfunk-session --list-audio` into ``(sinks, sources)``.
|
||||
|
||||
Its format is one endpoint per line, ``sink|source<TAB>node.name<TAB>description``. The
|
||||
node.name is what gets STORED (it is the stable id the client resolves against), so a line
|
||||
without one is unusable and dropped; a missing description falls back to the name rather than
|
||||
rendering a picker entry with no label. Anything else on the line is ignored, so an extra
|
||||
trailing column in a future client can't break this.
|
||||
"""
|
||||
sinks: list[dict] = []
|
||||
sources: list[dict] = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3 or not parts[1].strip():
|
||||
continue
|
||||
kind, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip()
|
||||
entry = {"name": name, "description": description or name}
|
||||
if kind == "sink":
|
||||
sinks.append(entry)
|
||||
elif kind == "source":
|
||||
sources.append(entry)
|
||||
return sinks, sources
|
||||
|
||||
|
||||
async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[int, str]:
|
||||
"""Run the SESSION binary headlessly, returning ``(returncode, stdout)``; ``(-1, "")`` when
|
||||
it isn't installed or the call errors/times out.
|
||||
|
||||
Only ever used for the two read-only device enumerations — the launch path goes through the
|
||||
Steam shortcut and the wrapper script, never through here. The timeout is generous because
|
||||
`--list-adapters` initialises Vulkan on a cold flatpak."""
|
||||
prefix = _session_argv()
|
||||
if not prefix:
|
||||
return -1, ""
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*prefix, *session_args,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
rc = proc.returncode if proc.returncode is not None else -1
|
||||
return rc, (out or b"").decode("utf-8", "replace")
|
||||
except asyncio.TimeoutError:
|
||||
decky.logger.warning("session %s timed out", " ".join(session_args))
|
||||
if proc:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return -1, ""
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("session %s failed", " ".join(session_args))
|
||||
return -1, ""
|
||||
|
||||
|
||||
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
|
||||
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
|
||||
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
|
||||
@@ -518,6 +601,11 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
|
||||
_HOSTS_TTL_S = 12.0
|
||||
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
|
||||
|
||||
# The settings tab's device lists (GPUs / audio endpoints). No TTL: this is hardware, and reading
|
||||
# it costs a Vulkan + PipeWire init. Held for the life of the plugin backend; `refresh_devices`
|
||||
# clears it for the user who just plugged a headset in.
|
||||
_devices_cache: dict = {"data": None}
|
||||
|
||||
|
||||
def _invalidate_hosts_cache() -> None:
|
||||
_hosts_cache["data"] = None
|
||||
@@ -1044,25 +1132,84 @@ class Plugin:
|
||||
try:
|
||||
return json.loads(_settings_path().read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad).
|
||||
# The client's own defaults (native display, host-default bitrate, auto pad,
|
||||
# stats overlay at Normal — `Settings::default` is `show_stats: true`).
|
||||
return {
|
||||
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
|
||||
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto",
|
||||
"gamepad_forwarding": True, "compositor": "auto",
|
||||
"inhibit_shortcuts": True, "mic_enabled": False,
|
||||
"stats_verbosity": "normal", "show_stats": True,
|
||||
}
|
||||
|
||||
async def set_settings(self, settings: dict) -> dict:
|
||||
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
|
||||
"""Write the stream settings JSON the (sandboxed) client reads on launch.
|
||||
|
||||
MERGED onto whatever is on disk, never a wholesale replace: this file is shared with
|
||||
the desktop client and the console's settings screen, and it holds far more keys than
|
||||
this panel models (decoder, GPU, profiles, touch/mouse model…). The panel reads it once
|
||||
when it mounts, so a straight write would post a snapshot that predates anything those
|
||||
other editors stored in the meantime — silently reverting it.
|
||||
"""
|
||||
try:
|
||||
d = _client_config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
_settings_path().write_text(json.dumps(settings, indent=2))
|
||||
try:
|
||||
on_disk = json.loads(_settings_path().read_text())
|
||||
if not isinstance(on_disk, dict):
|
||||
on_disk = {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
on_disk = {} # no file yet (or an unreadable one): this write creates it
|
||||
on_disk.update(settings)
|
||||
_settings_path().write_text(json.dumps(on_disk, indent=2))
|
||||
return {"ok": True}
|
||||
except OSError as exc:
|
||||
decky.logger.exception("could not write settings")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
async def list_devices(self) -> dict:
|
||||
"""GPUs + audio endpoints for the settings tab's device pickers.
|
||||
|
||||
Two subprocesses that initialise Vulkan and PipeWire, so the result is cached for the
|
||||
Decky session: hardware doesn't come and go often enough to justify paying that on every
|
||||
remount of the page, and a stale entry is harmless — a picked device that has since
|
||||
vanished falls back to the OS default in the client anyway. `refresh_devices` clears it.
|
||||
|
||||
Best-effort in the same way every other client call here is: no session binary (an old
|
||||
flatpak that predates the two-binary split, or a native install missing its sibling) just
|
||||
means empty lists and `ok: false`, which the UI shows as "couldn't read" rather than as
|
||||
"you have no devices".
|
||||
"""
|
||||
if _devices_cache["data"] is not None:
|
||||
return _devices_cache["data"]
|
||||
|
||||
adapters: list[str] = []
|
||||
sinks: list[dict] = []
|
||||
sources: list[dict] = []
|
||||
rc_a, out_a = await _run_session(["--list-adapters"])
|
||||
if rc_a == 0:
|
||||
adapters = [ln.strip() for ln in out_a.splitlines() if ln.strip()]
|
||||
rc_d, out_d = await _run_session(["--list-audio"])
|
||||
if rc_d == 0:
|
||||
sinks, sources = _parse_audio_endpoints(out_d)
|
||||
|
||||
result = {
|
||||
"ok": rc_a == 0 or rc_d == 0,
|
||||
"adapters": adapters,
|
||||
"sinks": sinks,
|
||||
"sources": sources,
|
||||
}
|
||||
# Only a run that actually answered is worth remembering — caching a failure would make
|
||||
# a client installed after the page was first opened stay invisible until a Decky restart.
|
||||
if result["ok"]:
|
||||
_devices_cache["data"] = result
|
||||
return result
|
||||
|
||||
async def refresh_devices(self) -> dict:
|
||||
"""Drop the cached enumeration and read it again (a headset was just plugged in)."""
|
||||
_devices_cache["data"] = None
|
||||
return await self.list_devices()
|
||||
|
||||
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
|
||||
|
||||
async def list_hosts(self, probe: bool = True) -> dict:
|
||||
|
||||
@@ -144,6 +144,33 @@ got = asyncio.run(plugin.get_pins())["pins"]
|
||||
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
|
||||
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
|
||||
|
||||
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
|
||||
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
|
||||
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
|
||||
)
|
||||
check("audio: sinks parsed", [d["name"] for d in sinks] == [
|
||||
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
|
||||
])
|
||||
check("audio: sources parsed", len(sources) == 1)
|
||||
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
|
||||
|
||||
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
|
||||
# short line is malformed, and an unknown kind belongs to neither list. A blank description
|
||||
# falls back to the name so no entry renders unlabelled.
|
||||
sinks, sources = main._parse_audio_endpoints(
|
||||
"sink\t\tNo node name\n"
|
||||
"sink\tonly-two-columns\n"
|
||||
"monitor\tsome.monitor\tNot a sink or source\n"
|
||||
"source\tbare.node\t\n"
|
||||
"\n"
|
||||
)
|
||||
check("audio: junk lines dropped", sinks == [])
|
||||
check("audio: blank description falls back to the node name", sources == [
|
||||
{"name": "bare.node", "description": "bare.node"}
|
||||
])
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{failures} check(s) FAILED")
|
||||
|
||||
@@ -101,27 +101,97 @@ export interface RunnerInfo {
|
||||
client_bin?: string;
|
||||
}
|
||||
|
||||
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
||||
// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched
|
||||
// because get_settings returns the whole parsed file and patches are object spreads.
|
||||
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
|
||||
// and the console's settings screen own, so a value changed in any of them shows in the others.
|
||||
//
|
||||
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
|
||||
// cannot be answered from a plugin backend or aren't settings at all:
|
||||
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
|
||||
// the client process has; there is no CLI that enumerates pads.
|
||||
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
|
||||
// preference anyone sets.
|
||||
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
|
||||
// spreads, and set_settings merges onto what's on disk.
|
||||
//
|
||||
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
|
||||
// that key existed simply lacks it. Read those through the same fallback the client uses —
|
||||
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
|
||||
// while the stream runs with it on.
|
||||
export interface StreamSettings {
|
||||
// ---- Stream mode ----
|
||||
width: number; // 0 = native
|
||||
height: number; // 0 = native
|
||||
refresh_hz: number; // 0 = native
|
||||
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
|
||||
bitrate_kbps: number; // 0 = host default
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
// Stream mode follows the session window instead of width/height, renegotiating on resize.
|
||||
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
|
||||
match_window?: boolean;
|
||||
|
||||
// ---- Video ----
|
||||
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
|
||||
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
|
||||
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
|
||||
enable_444?: boolean; // default off — ask for full chroma
|
||||
adapter?: string; // decode/present GPU by marketing name; "" = automatic
|
||||
|
||||
// ---- Presentation ----
|
||||
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
|
||||
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
|
||||
present_priority?: string;
|
||||
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 1–3
|
||||
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
|
||||
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
|
||||
|
||||
// ---- Audio ----
|
||||
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
|
||||
speaker_device?: string; // PipeWire node.name for playback; "" = system default
|
||||
mic_enabled: boolean;
|
||||
mic_device?: string; // PipeWire node.name for capture; "" = system default
|
||||
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
|
||||
|
||||
// ---- Controllers ----
|
||||
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
|
||||
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
|
||||
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
|
||||
gamepad_forwarding?: boolean;
|
||||
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
|
||||
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
|
||||
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
|
||||
// compositor shortcuts to inhibit and hands the focused window every key already. A toggle
|
||||
// here would be a dead one. The desktop client's row still edits this same file.
|
||||
|
||||
// ---- Touchscreen, mouse & keyboard ----
|
||||
touch_mode?: string; // "trackpad" | "pointer" | "touch"
|
||||
mouse_mode?: string; // "capture" | "desktop"
|
||||
invert_scroll?: boolean;
|
||||
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
|
||||
inhibit_shortcuts: boolean;
|
||||
mic_enabled: boolean;
|
||||
|
||||
// ---- Interface & behaviour ----
|
||||
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
|
||||
// which resolves through `show_stats` — read both the way the client's
|
||||
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
|
||||
stats_verbosity?: string;
|
||||
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
|
||||
// tiers still honours an Off chosen here.
|
||||
show_stats?: boolean;
|
||||
fullscreen_on_stream?: boolean;
|
||||
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
|
||||
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
|
||||
}
|
||||
|
||||
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
|
||||
// human name to show.
|
||||
export interface AudioDevice {
|
||||
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
|
||||
description: string; // human label ("Steam Deck Speakers")
|
||||
}
|
||||
|
||||
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
|
||||
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
|
||||
// pickers stay on their stored value rather than pretending the device is gone.
|
||||
export interface DeviceLists {
|
||||
ok: boolean;
|
||||
adapters: string[]; // Vulkan physical devices, discrete first
|
||||
sinks: AudioDevice[]; // playback endpoints
|
||||
sources: AudioDevice[]; // capture endpoints
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
@@ -188,6 +258,11 @@ export const getSettings = callable<[], StreamSettings>("get_settings");
|
||||
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
|
||||
"set_settings",
|
||||
);
|
||||
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
|
||||
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
|
||||
export const listDevices = callable<[], DeviceLists>("list_devices");
|
||||
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
|
||||
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
|
||||
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
|
||||
|
||||
@@ -334,8 +334,14 @@ const HostsTab: FC<{
|
||||
</div>
|
||||
);
|
||||
|
||||
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
|
||||
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
|
||||
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
|
||||
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
|
||||
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
|
||||
|
||||
const SettingsTab: FC = () => (
|
||||
<div style={tabScroll}>
|
||||
<div style={settingsPane}>
|
||||
<SettingsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
+608
-162
@@ -1,10 +1,59 @@
|
||||
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
|
||||
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
|
||||
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
|
||||
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
|
||||
import { CSSProperties, FC, useEffect, useState } from "react";
|
||||
import { getSettings, setSettings, StreamSettings } from "./backend";
|
||||
import { RowActions } from "./ui";
|
||||
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
|
||||
// launch (main.py set_settings, merged onto what's on disk). This is the same
|
||||
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
|
||||
// changed in any of the three shows in the other two.
|
||||
//
|
||||
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
|
||||
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
|
||||
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
|
||||
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
|
||||
//
|
||||
// The categories, their order, and the wording of the rows are the console's settings screen
|
||||
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
|
||||
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
|
||||
// trusting either. It shows them as one steppable list because it has no pointer and no room for
|
||||
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
|
||||
//
|
||||
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
|
||||
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
|
||||
// the one above it is a moving target for a thumbstick.
|
||||
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
|
||||
// one-GPU Deck). A dead control is worse than an absent one.
|
||||
// • Anything that behaves differently *here* than it does on a desktop says so in its own
|
||||
// description, rather than being silently dropped from the screen.
|
||||
//
|
||||
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
|
||||
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
|
||||
// `MouseMode` enums, which serialize lowercase.
|
||||
import {
|
||||
DialogButton,
|
||||
Dropdown,
|
||||
Field,
|
||||
SidebarNavigation,
|
||||
SliderField,
|
||||
Spinner,
|
||||
ToggleField,
|
||||
} from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
FaDesktop,
|
||||
FaGamepad,
|
||||
FaHandPointer,
|
||||
FaSlidersH,
|
||||
FaTv,
|
||||
FaVideo,
|
||||
FaVolumeUp,
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
AudioDevice,
|
||||
DeviceLists,
|
||||
getSettings,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
setSettings,
|
||||
StreamSettings,
|
||||
} from "./backend";
|
||||
import { actionButton, RowActions } from "./ui";
|
||||
|
||||
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
|
||||
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
|
||||
@@ -17,50 +66,543 @@ const selectShell: CSSProperties = {
|
||||
maxWidth: "24em",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
|
||||
// Match window is offered even though this plugin's launches are always fullscreen (where it
|
||||
// degenerates to the display's native mode) — leaving it out would make the row lie about a
|
||||
// store the desktop client can set it in.
|
||||
const MATCH_WINDOW = "match";
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
[1280, 720, "1280 × 720"],
|
||||
[1280, 800, "1280 × 800 (Deck)"],
|
||||
[1920, 1080, "1920 × 1080"],
|
||||
[2560, 1440, "2560 × 1440"],
|
||||
[3840, 2160, "3840 × 2160"],
|
||||
];
|
||||
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
|
||||
|
||||
const REFRESH = [0, 30, 60, 90, 120];
|
||||
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
|
||||
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
const renderScaleLabel = (x: number): string =>
|
||||
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
|
||||
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
|
||||
const GAMEPAD_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
xbox360: "Xbox 360",
|
||||
xboxone: "Xbox One",
|
||||
dualsense: "DualSense",
|
||||
dualshock4: "DualShock 4",
|
||||
steamdeck: "Steam Deck",
|
||||
|
||||
const COMPOSITORS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["kwin", "KDE Plasma (KWin)"],
|
||||
["wlroots", "Sway (wlroots)"],
|
||||
["mutter", "GNOME (Mutter)"],
|
||||
["gamescope", "gamescope"],
|
||||
];
|
||||
const CODECS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["hevc", "HEVC (H.265)"],
|
||||
["h264", "H.264 (AVC)"],
|
||||
["av1", "AV1"],
|
||||
// Opt-in wired-LAN low-latency codec (100–400 Mbit/s class, 8-bit SDR). Only ever selected
|
||||
// when the host advertises it too; anything else falls back to HEVC.
|
||||
["pyrowave", "PyroWave (wired LAN)"],
|
||||
];
|
||||
const DECODERS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["vulkan", "Vulkan Video"],
|
||||
["vaapi", "VAAPI"],
|
||||
["software", "Software"],
|
||||
];
|
||||
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
|
||||
// one profile reads the same on every device.
|
||||
const PRESENT_PRIORITIES: [string, string][] = [
|
||||
["latency", "Lowest latency"],
|
||||
["smooth", "Smoothness"],
|
||||
];
|
||||
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
|
||||
const SMOOTH_BUFFERS: [number, string][] = [
|
||||
[0, "Automatic"],
|
||||
[1, "1 frame"],
|
||||
[2, "2 frames"],
|
||||
[3, "3 frames"],
|
||||
];
|
||||
const AUDIO_CHANNELS: [number, string][] = [
|
||||
[2, "Stereo"],
|
||||
[6, "5.1 surround"],
|
||||
[8, "7.1 surround"],
|
||||
];
|
||||
const GAMEPADS: [string, string][] = [
|
||||
["auto", "Automatic"],
|
||||
["xbox360", "Xbox 360"],
|
||||
["xboxone", "Xbox One"],
|
||||
["dualsense", "DualSense"],
|
||||
["dualshock4", "DualShock 4"],
|
||||
["steamdeck", "Steam Deck"],
|
||||
];
|
||||
const TOUCH_MODES: [string, string][] = [
|
||||
["trackpad", "Trackpad"],
|
||||
["pointer", "Direct pointer"],
|
||||
["touch", "Touch passthrough"],
|
||||
];
|
||||
const MOUSE_MODES: [string, string][] = [
|
||||
["capture", "Capture (games)"],
|
||||
["desktop", "Desktop (absolute)"],
|
||||
];
|
||||
const STATS_TIERS: [string, string][] = [
|
||||
["off", "Off"],
|
||||
["compact", "Compact"],
|
||||
["normal", "Normal"],
|
||||
["detailed", "Detailed"],
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
|
||||
// twelve of them below stay one line each and can't drift apart.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
const SelectRow = <T extends string | number>({
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
formatUnknown,
|
||||
disabled,
|
||||
indent,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
options: [T, string][];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
|
||||
formatUnknown?: (v: T) => string;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}): ReactElement => {
|
||||
// A Dropdown can only display a value that is one of its options, and this store has four other
|
||||
// writers — the desktop client, the console, a settings profile, a newer client with presets
|
||||
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
|
||||
// different value than the stream will actually use), carry the stored one as its own entry.
|
||||
const shown: [T, string][] = options.some(([v]) => v === value)
|
||||
? options
|
||||
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
|
||||
return (
|
||||
<Field
|
||||
label={label}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
indentLevel={indent ? 1 : undefined}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
|
||||
selectedOption={value}
|
||||
onChange={(o) => onChange(o.data as T)}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host
|
||||
// falls back from when its GPU can't encode it.
|
||||
const CODECS = ["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
hevc: "HEVC (H.265)",
|
||||
h264: "H.264 (AVC)",
|
||||
av1: "AV1",
|
||||
|
||||
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
|
||||
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
|
||||
// a real preference that simply isn't plugged in right now, and dropping it would silently
|
||||
// re-point the next stream at the default without ever showing the user why.
|
||||
const DeviceRow: FC<{
|
||||
label: string;
|
||||
description: string;
|
||||
devices: AudioDevice[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
indent?: boolean;
|
||||
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
|
||||
const options: [string, string][] = [["", "System default"]];
|
||||
for (const d of devices ?? []) options.push([d.name, d.description]);
|
||||
if (value && !options.some(([name]) => name === value)) {
|
||||
options.push([value, `${value} (not connected)`]);
|
||||
}
|
||||
return (
|
||||
<SelectRow
|
||||
label={label}
|
||||
description={devices === null ? "Reading this device's audio endpoints…" : description}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled || devices === null}
|
||||
indent={indent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
const COMPOSITOR_LABELS: Record<string, string> = {
|
||||
auto: "Automatic",
|
||||
kwin: "KDE Plasma (KWin)",
|
||||
wlroots: "Sway (wlroots)",
|
||||
mutter: "GNOME (Mutter)",
|
||||
gamescope: "gamescope",
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// The pages. One settings object, seven views on it — every page takes the same context rather
|
||||
// than fetching or holding state of its own, so a change on one page is visible on the others
|
||||
// the moment you switch.
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
interface PageCtx {
|
||||
s: StreamSettings;
|
||||
patch: (p: Partial<StreamSettings>) => void;
|
||||
devices: DeviceLists | null;
|
||||
reading: boolean;
|
||||
readDevices: (again: boolean) => void;
|
||||
}
|
||||
|
||||
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
|
||||
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
|
||||
// inset the tabs use).
|
||||
const pageBody: CSSProperties = { paddingBottom: "80px" };
|
||||
|
||||
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const renderScale = s.render_scale ?? 1;
|
||||
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Resolution"
|
||||
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
|
||||
options={[
|
||||
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
|
||||
[MATCH_WINDOW, "Match window"] as [string, string],
|
||||
]}
|
||||
value={resolution}
|
||||
// A size set from a desktop profile that isn't one of these presets, spelled the way the
|
||||
// presets are rather than left as the raw "1600x900" key.
|
||||
formatUnknown={(v) => v.replace("x", " × ")}
|
||||
onChange={(v) => {
|
||||
if (v === MATCH_WINDOW) {
|
||||
// The tri-state the console stores: the flag on, the explicit size cleared.
|
||||
patch({ match_window: true, width: 0, height: 0 });
|
||||
return;
|
||||
}
|
||||
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
|
||||
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
|
||||
}}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Refresh rate"
|
||||
description="Native follows the display the stream is on."
|
||||
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
|
||||
value={s.refresh_hz}
|
||||
formatUnknown={(v) => `${v} Hz`}
|
||||
onChange={(v) => patch({ refresh_hz: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Render scale"
|
||||
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
|
||||
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
value={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
|
||||
)}
|
||||
onChange={(v) => patch({ render_scale: v })}
|
||||
/>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="0 = the host's own default (20 Mbit/s)."
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Host compositor"
|
||||
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
|
||||
options={COMPOSITORS}
|
||||
value={s.compositor}
|
||||
onChange={(v) => patch({ compositor: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
|
||||
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
|
||||
// picker with a single option is a control that can't do anything.
|
||||
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Video codec"
|
||||
description="A preference — the host falls back when its GPU can't encode this one."
|
||||
options={CODECS}
|
||||
value={s.codec ?? "auto"}
|
||||
onChange={(v) => patch({ codec: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Video decoder"
|
||||
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
|
||||
options={DECODERS}
|
||||
value={s.decoder ?? "auto"}
|
||||
onChange={(v) => patch({ decoder: v })}
|
||||
/>
|
||||
{showGpuRow && (
|
||||
<SelectRow
|
||||
label="Decode GPU"
|
||||
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
|
||||
options={[
|
||||
["", "Automatic"],
|
||||
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
|
||||
]}
|
||||
value={s.adapter ?? ""}
|
||||
onChange={(v) => patch({ adapter: v })}
|
||||
/>
|
||||
)}
|
||||
<ToggleField
|
||||
label="10-bit HDR"
|
||||
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
|
||||
checked={s.hdr_enabled ?? true}
|
||||
onChange={(v) => patch({ hdr_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Full chroma (4:4:4)"
|
||||
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
|
||||
checked={s.enable_444 ?? false}
|
||||
onChange={(v) => patch({ enable_444: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const smooth = (s.present_priority ?? "latency") === "smooth";
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Prioritize"
|
||||
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
|
||||
options={PRESENT_PRIORITIES}
|
||||
value={s.present_priority ?? "latency"}
|
||||
onChange={(v) => patch({ present_priority: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Smoothness buffer"
|
||||
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
|
||||
options={SMOOTH_BUFFERS}
|
||||
value={s.smooth_buffer ?? 0}
|
||||
formatUnknown={(v) => `${v} frames`}
|
||||
onChange={(v) => patch({ smooth_buffer: v })}
|
||||
disabled={!smooth}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="V-Sync"
|
||||
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
|
||||
checked={s.vsync ?? true}
|
||||
onChange={(v) => patch({ vsync: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Follow variable refresh"
|
||||
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
|
||||
checked={s.allow_vrr ?? true}
|
||||
onChange={(v) => patch({ allow_vrr: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
|
||||
const micOn = s.mic_enabled;
|
||||
// What the pickers get: null while the enumeration is in flight (they show a loading state),
|
||||
// [] when it answered but couldn't read the endpoints (System default plus whatever is
|
||||
// stored), and the real list otherwise.
|
||||
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
|
||||
reading || !devices ? null : devices.ok ? (list ?? []) : [];
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Audio channels"
|
||||
description="The speaker layout requested from the host, which clamps it to what it can capture."
|
||||
options={AUDIO_CHANNELS}
|
||||
value={s.audio_channels ?? 2}
|
||||
formatUnknown={(v) => `${v} channels`}
|
||||
onChange={(v) => patch({ audio_channels: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Output device"
|
||||
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
|
||||
devices={endpoints(devices?.sinks)}
|
||||
value={s.speaker_device ?? ""}
|
||||
onChange={(v) => patch({ speaker_device: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
|
||||
checked={micOn}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
<DeviceRow
|
||||
label="Microphone device"
|
||||
description="Which input the mic uplink captures from."
|
||||
devices={endpoints(devices?.sources)}
|
||||
value={s.mic_device ?? ""}
|
||||
onChange={(v) => patch({ mic_device: v })}
|
||||
disabled={!micOn}
|
||||
indent
|
||||
/>
|
||||
<ToggleField
|
||||
label="Echo cancellation"
|
||||
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
|
||||
checked={s.echo_cancel ?? true}
|
||||
onChange={(v) => patch({ echo_cancel: v })}
|
||||
disabled={!micOn}
|
||||
indentLevel={1}
|
||||
/>
|
||||
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
|
||||
answer when the enumeration failed outright (a client too old to ship the session
|
||||
binary). Rendered unconditionally, including while it is reading: a row that comes and
|
||||
goes under a thumbstick is a moving target, so only its wording changes. */}
|
||||
<Field
|
||||
label={
|
||||
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
|
||||
}
|
||||
description={
|
||||
reading
|
||||
? "Reading this device's audio endpoints and GPUs…"
|
||||
: devices && !devices.ok
|
||||
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
|
||||
: "Plugged something in just now? Read the audio endpoints and GPUs again."
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
|
||||
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
|
||||
const forwarding = s.gamepad_forwarding ?? true;
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<ToggleField
|
||||
label="Forward controllers"
|
||||
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
|
||||
checked={forwarding}
|
||||
onChange={(v) => patch({ gamepad_forwarding: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Controller type"
|
||||
description="The virtual pad the host creates. Automatic matches the controller you're holding."
|
||||
options={GAMEPADS}
|
||||
value={s.gamepad}
|
||||
onChange={(v) => patch({ gamepad: v })}
|
||||
disabled={!forwarding}
|
||||
indent
|
||||
/>
|
||||
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
indentLevel={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Touch mode"
|
||||
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
|
||||
options={TOUCH_MODES}
|
||||
value={s.touch_mode ?? "trackpad"}
|
||||
onChange={(v) => patch({ touch_mode: v })}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Mouse mode"
|
||||
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
|
||||
options={MOUSE_MODES}
|
||||
value={s.mouse_mode ?? "capture"}
|
||||
onChange={(v) => patch({ mouse_mode: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Invert scroll direction"
|
||||
description="Reverses the wheel and trackpad scroll direction sent to the host."
|
||||
checked={s.invert_scroll ?? false}
|
||||
onChange={(v) => patch({ invert_scroll: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Capture system shortcuts"
|
||||
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
|
||||
checked={s.inhibit_shortcuts}
|
||||
onChange={(v) => patch({ inhibit_shortcuts: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
|
||||
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
|
||||
// which itself defaults to true.
|
||||
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
|
||||
return (
|
||||
<div style={pageBody}>
|
||||
<SelectRow
|
||||
label="Statistics overlay"
|
||||
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
|
||||
options={STATS_TIERS}
|
||||
value={statsTier}
|
||||
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
|
||||
// client too old for the tiers still honours an Off chosen here.
|
||||
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Wake hosts automatically"
|
||||
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
|
||||
checked={s.auto_wake ?? true}
|
||||
onChange={(v) => patch({ auto_wake: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Show game library in the client"
|
||||
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
|
||||
checked={s.library_enabled ?? false}
|
||||
onChange={(v) => patch({ library_enabled: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Start streams fullscreen"
|
||||
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
|
||||
checked={s.fullscreen_on_stream ?? true}
|
||||
onChange={(v) => patch({ fullscreen_on_stream: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
export const SettingsSection: FC = () => {
|
||||
const [s, setS] = useState<StreamSettings | null>(null);
|
||||
// null until the enumeration answers — the pickers show a loading state rather than briefly
|
||||
// claiming this device has no endpoints.
|
||||
const [devices, setDevices] = useState<DeviceLists | null>(null);
|
||||
const [reading, setReading] = useState(true);
|
||||
|
||||
const readDevices = (again: boolean) => {
|
||||
setReading(true);
|
||||
void (again ? refreshDevices() : listDevices())
|
||||
.then(setDevices)
|
||||
.finally(() => setReading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getSettings().then(setS);
|
||||
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
|
||||
// takes seconds, and the rest of the screen must not wait for it.
|
||||
readDevices(false);
|
||||
}, []);
|
||||
|
||||
const patch = (p: Partial<StreamSettings>) => {
|
||||
@@ -74,138 +616,42 @@ export const SettingsSection: FC = () => {
|
||||
|
||||
if (!s) return <Spinner style={{ height: "1.5em" }} />;
|
||||
|
||||
const resIdx = Math.max(
|
||||
0,
|
||||
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
|
||||
);
|
||||
|
||||
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
label="Resolution"
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
onChange={(o) => {
|
||||
const [w, h] = RESOLUTIONS[o.data as number];
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||||
selectedOption={s.refresh_hz}
|
||||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
label="Render scale"
|
||||
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
|
||||
// Snap the stored value to the nearest preset so the dropdown always shows a match.
|
||||
selectedOption={RENDER_SCALES.reduce((best, x) =>
|
||||
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
|
||||
)}
|
||||
onChange={(o) => patch({ render_scale: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
description="Mbit/s · 0 = host default"
|
||||
value={Math.round(s.bitrate_kbps / 1000)}
|
||||
min={0}
|
||||
max={150}
|
||||
step={5}
|
||||
showValue
|
||||
valueSuffix=" Mbit/s"
|
||||
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
|
||||
/>
|
||||
<Field
|
||||
label="Video codec"
|
||||
description="Preferred stream codec — the host falls back when its GPU can't encode it"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={CODECS.map((c) => ({ data: c, label: CODEC_LABELS[c] ?? c }))}
|
||||
selectedOption={s.codec ?? "auto"}
|
||||
onChange={(o) => patch({ codec: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Forward controllers"
|
||||
description="Send this Deck's controllers to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
|
||||
checked={s.gamepad_forwarding ?? true}
|
||||
onChange={(v) => patch({ gamepad_forwarding: v })}
|
||||
/>
|
||||
{(s.gamepad_forwarding ?? true) && (
|
||||
<>
|
||||
<Field
|
||||
label="Gamepad type"
|
||||
description="Which virtual controller the host creates for your inputs"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
label="⚠ Disable Steam Input"
|
||||
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
label="Host compositor"
|
||||
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
|
||||
selectedOption={s.compositor}
|
||||
onChange={(o) => patch({ compositor: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
description="Send the Deck's microphone to the host's virtual mic"
|
||||
checked={s.mic_enabled}
|
||||
onChange={(v) => patch({ mic_enabled: v })}
|
||||
/>
|
||||
</>
|
||||
<SidebarNavigation
|
||||
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
|
||||
// reporting would have this nav push entries of its own onto the router and fight the
|
||||
// page for the back gesture; the pages are addressed by `identifier` instead.
|
||||
disableRouteReporting
|
||||
pages={[
|
||||
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
|
||||
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
|
||||
{
|
||||
title: "Presentation",
|
||||
identifier: "presentation",
|
||||
icon: <FaTv />,
|
||||
content: <PresentationPage {...ctx} />,
|
||||
},
|
||||
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
|
||||
{
|
||||
title: "Controllers",
|
||||
identifier: "controllers",
|
||||
icon: <FaGamepad />,
|
||||
content: <ControllersPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Touch & mouse",
|
||||
identifier: "pointer",
|
||||
icon: <FaHandPointer />,
|
||||
content: <PointerPage {...ctx} />,
|
||||
},
|
||||
{
|
||||
title: "Interface",
|
||||
identifier: "interface",
|
||||
icon: <FaSlidersH />,
|
||||
content: <InterfacePage {...ctx} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+469
-108
@@ -24,17 +24,19 @@
|
||||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
|
||||
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
|
||||
//! (heavy-but-recoverable loss, an OWD rise, a decode rise) needs two consecutive bad windows.
|
||||
//! Recovery is two-mode: **slow start** — until the first congestion signal the rate DOUBLES each
|
||||
//! clean window (cooldown-paced), which is how an Automatic session climbs from the conservative
|
||||
//! start to the [`set_ceiling`](BitrateController::set_ceiling) measured by the startup
|
||||
//! link-capacity probe in seconds instead of minutes — then classic additive recovery (+~6 %
|
||||
//! after ~4.5 s clean, ceilinged). Changes are rate-limited (each one costs the IDR the host's
|
||||
//! Recovery is two-mode: **slow start** — until the first congestion signal each clean window
|
||||
//! asks for double the current rate, bounded (like every climb) by the proven-throughput
|
||||
//! headroom below, so the step a loaded session actually takes is ×1.5 over what it last
|
||||
//! delivered; either way it climbs from the conservative start to the
|
||||
//! [`set_ceiling`](BitrateController::set_ceiling) measured by the startup link-capacity probe
|
||||
//! in seconds rather than minutes — then classic additive recovery (+~6 % after ~4.5 s clean,
|
||||
//! ceilinged). Changes are rate-limited (each one costs the IDR the host's
|
||||
//! rebuilt encoder opens with) and the whole controller disables itself against a host that never
|
||||
//! answers [`crate::quic::BitrateChanged`] (an older build that ignores unknown control messages).
|
||||
//! Standing limits are LEARNED rather than re-poked: two identical short host acks latch the
|
||||
//! encoder's ceiling (`host_cap_kbps`), two consecutive decode-severe backoffs at a similar rate
|
||||
//! latch the client decoder's knee (`decode_cap_kbps`) — and both re-probe slowly
|
||||
//! ([`CAP_REPROBE_WINDOWS`]) so neither latch outlives the condition that taught it.
|
||||
//! ([`CAP_REPROBE_WINDOWS_MIN`]) so neither latch outlives the condition that taught it.
|
||||
//!
|
||||
//! Climbs are additionally **evidence-gated**. The target is only a *promise* to the encoder —
|
||||
//! how many bits it actually emits depends on the content — so on calm content (a menu, an idle
|
||||
@@ -128,15 +130,26 @@ const ENCODE_RISE_US: i64 = 4_000;
|
||||
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
|
||||
/// queue is growing past the knee; skip the two-window confirmation.
|
||||
const ENCODE_SEVERE_US: i64 = 12_000;
|
||||
/// Clean windows parked at the learned [`host cap`](BitrateController::host_cap_kbps) before
|
||||
/// re-probing above it (~60 s at the 750 ms tick). A cadence-refusal cap is scene-dependent
|
||||
/// evidence, not a spec limit — without a re-probe, one heavy scene would cap the whole
|
||||
/// session. A still-standing limit just re-teaches itself in two short acks, which the host
|
||||
/// pre-clamps without touching the encoder — the re-probe costs no rebuild, no IDR.
|
||||
/// The [`decode cap`](BitrateController::decode_cap_kbps) re-probes on the same clock for the
|
||||
/// same reason: the decoder's knee moves with content and thermals, so its latch must not be
|
||||
/// permanent either.
|
||||
const CAP_REPROBE_WINDOWS: u32 = 80;
|
||||
/// Clean windows parked at a learned cap before re-probing above it, and the ceiling that
|
||||
/// interval backs off to.
|
||||
///
|
||||
/// A learned cap is EVIDENCE, not a spec limit: the host's short ack means "not right now",
|
||||
/// which covers both its encoder's codec-level ceiling (durable) and a climb refused while
|
||||
/// encode is behind cadence (transient, and routinely latched during slow start at the
|
||||
/// conservative 20 Mbps default). The client cannot tell those apart from the ack alone, so the
|
||||
/// re-probe is what keeps a transient from becoming the session's ceiling — and a flat ~60 s
|
||||
/// clock at +12.5 % made that escape take upwards of twenty minutes to cross the gap to a
|
||||
/// probe-measured link ceiling, which is indistinguishable from never.
|
||||
///
|
||||
/// So: probe again after 12 s, and DOUBLE the interval each time the lift is immediately
|
||||
/// re-learned at the same value (see [`on_ack`](BitrateController::on_ack)). A transient is out
|
||||
/// in one interval; a standing limit settles into a slow poll instead of a permanent one. The
|
||||
/// re-probe itself is nearly free either way — a still-standing limit re-teaches itself in two
|
||||
/// short acks, which the host pre-clamps without touching the encoder: no rebuild, no IDR.
|
||||
/// The [`decode cap`](BitrateController::decode_cap_kbps) re-probes on the same schedule for
|
||||
/// the same reason: the decoder's knee moves with content and thermals.
|
||||
const CAP_REPROBE_WINDOWS_MIN: u32 = 16;
|
||||
const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
|
||||
/// Two consecutive decode-driven backoffs latch the
|
||||
/// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree
|
||||
/// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its
|
||||
@@ -146,6 +159,17 @@ const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
/// Windows a rolling baseline must hold before the signal it feeds may fire. A baseline is a
|
||||
/// rolling MINIMUM, so a single sample IS the baseline — and if that one window landed on calm
|
||||
/// content, ordinary content variance clears the rise threshold by itself. That hole is not
|
||||
/// theoretical: [`on_ack`](BitrateController::on_ack) deliberately CLEARS the encode baseline
|
||||
/// after every decrease we ourselves asked for, so the encode down-driver re-armed on a
|
||||
/// one-sample floor each time — a calm re-seed window followed by a motion scene reads as
|
||||
/// `ENCODE_RISE_US` of "congestion", backs off, clears again, and ratchets to the floor on a
|
||||
/// link that was never the problem. Four windows (3 s) of evidence before any of the three
|
||||
/// latency signals may fire costs a little reaction latency at session start and buys a floor
|
||||
/// that means something.
|
||||
const BASELINE_MIN_WINDOWS: usize = 4;
|
||||
/// Requests sent without a single [`crate::quic::BitrateChanged`] ack before concluding the host
|
||||
/// predates bitrate renegotiation and going quiet for the rest of the session.
|
||||
const MAX_UNACKED: u32 = 3;
|
||||
@@ -167,6 +191,37 @@ fn ceiling_cap_from_env() -> Option<u32> {
|
||||
.map(|m| m.saturating_mul(1_000))
|
||||
}
|
||||
|
||||
/// Score one window's latency sample against its rolling-min baseline, then record it.
|
||||
///
|
||||
/// Shared by all three latency signals (OWD, client decode, host encode) — same shape, different
|
||||
/// thresholds. `mean` is `None` when nobody reports the signal (no clock handshake, an embedder
|
||||
/// that doesn't measure decode, a host that ships no stage timings); the signal is then simply
|
||||
/// absent rather than clean, so it can neither mark a window bad nor teach a baseline.
|
||||
///
|
||||
/// The baseline is the minimum of the PRIOR windows — this window is compared before it is
|
||||
/// recorded, so a rising window can't drag its own floor up with it — and only counts once
|
||||
/// [`BASELINE_MIN_WINDOWS`] of them exist. Returns `(rise, severe)`; pass `i64::MAX` for
|
||||
/// `severe_us` on a signal with no severe tier.
|
||||
fn score_baseline(
|
||||
means: &mut VecDeque<i64>,
|
||||
mean: Option<i64>,
|
||||
rise_us: i64,
|
||||
severe_us: i64,
|
||||
) -> (bool, bool) {
|
||||
let Some(mean) = mean else {
|
||||
return (false, false);
|
||||
};
|
||||
let base = (means.len() >= BASELINE_MIN_WINDOWS)
|
||||
.then(|| means.iter().min().copied())
|
||||
.flatten();
|
||||
let over = |t: i64| base.is_some_and(|b| mean > b.saturating_add(t));
|
||||
if means.len() == BASELINE_WINDOWS {
|
||||
means.pop_front();
|
||||
}
|
||||
means.push_back(mean);
|
||||
(over(rise_us), over(severe_us))
|
||||
}
|
||||
|
||||
/// One decision per report window; `Some(kbps)` = send a [`crate::quic::SetBitrate`].
|
||||
pub(crate) struct BitrateController {
|
||||
/// `false` = permanently off (explicit user bitrate, an old host, or ack silence).
|
||||
@@ -199,7 +254,7 @@ pub(crate) struct BitrateController {
|
||||
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
|
||||
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
|
||||
/// the probe-measured link authority survives a mode switch's reset. Slowly re-probed
|
||||
/// ([`CAP_REPROBE_WINDOWS`]) so scene-dependent evidence can't cap the session forever.
|
||||
/// ([`CAP_REPROBE_WINDOWS_MIN`]) so scene-dependent evidence can't cap the session forever.
|
||||
host_cap_kbps: Option<u32>,
|
||||
/// The rate the last [`request`](Self::request) asked for — the reference an ack is judged
|
||||
/// short against. Taken (not kept) by the ack, so one request is judged at most once.
|
||||
@@ -210,8 +265,11 @@ pub(crate) struct BitrateController {
|
||||
/// deterministic min()s, so a persistent limit reproduces exactly.
|
||||
short_ack_kbps: u32,
|
||||
short_acks: u32,
|
||||
/// Clean windows spent parked at the learned cap (the re-probe clock).
|
||||
/// Clean windows spent parked at the learned cap (the re-probe clock) and the interval it is
|
||||
/// counting toward — [`CAP_REPROBE_WINDOWS_MIN`], doubled toward
|
||||
/// [`CAP_REPROBE_WINDOWS_MAX`] each time a lift is immediately re-learned.
|
||||
cap_probe_windows: u32,
|
||||
cap_reprobe_after: u32,
|
||||
/// The client-decoder rate cap, mirroring [`host_cap_kbps`](Self::host_cap_kbps) for the
|
||||
/// OTHER end of the pipe: latched when two CONSECUTIVE backoffs carried decode-severe
|
||||
/// evidence (a deep decode-latency excursion, or a jump-to-live flush — in the
|
||||
@@ -220,7 +278,7 @@ pub(crate) struct BitrateController {
|
||||
/// ceiling is a permanent 30–60 s sawtooth: every ×0.7 backoff re-climbs toward a ceiling
|
||||
/// the decoder can't hold, and each cycle costs a flush plus a dropped-frame burst (the
|
||||
/// 1440p120 HEVC field case: knee ~490 Mbps under a ~658 Mbps ceiling). Slowly re-probed
|
||||
/// on the [`CAP_REPROBE_WINDOWS`] clock, exactly like the host cap, so a decoder that
|
||||
/// on the [`CAP_REPROBE_WINDOWS_MIN`] clock, exactly like the host cap, so a decoder that
|
||||
/// recovers (lighter content, thermal headroom) climbs again — the latch is never
|
||||
/// permanent.
|
||||
decode_cap_kbps: Option<u32>,
|
||||
@@ -228,8 +286,10 @@ pub(crate) struct BitrateController {
|
||||
/// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`])
|
||||
/// to latch the cap — one spurious flush teaches nothing.
|
||||
decode_backoff_kbps: u32,
|
||||
/// Clean windows spent parked at the learned decode cap (its re-probe clock).
|
||||
/// Clean windows spent parked at the learned decode cap (its re-probe clock), and that
|
||||
/// clock's own backoff interval — same schedule as the host cap's.
|
||||
decode_cap_probe_windows: u32,
|
||||
decode_cap_reprobe_after: u32,
|
||||
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
|
||||
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
|
||||
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
|
||||
@@ -241,6 +301,10 @@ pub(crate) struct BitrateController {
|
||||
last_change: Option<Instant>,
|
||||
/// Requests since the last ack — reaching [`MAX_UNACKED`] disables the controller.
|
||||
unacked: u32,
|
||||
/// The last ceiling-clamp target asked for (0 = none). A session running ABOVE its effective
|
||||
/// ceiling is asked down to it exactly once per distinct target — a host that answers higher
|
||||
/// has said it cannot go there, and re-asking every cooldown only costs reconfigures.
|
||||
ceiling_ask_kbps: u32,
|
||||
}
|
||||
|
||||
impl BitrateController {
|
||||
@@ -257,7 +321,12 @@ impl BitrateController {
|
||||
BitrateController {
|
||||
enabled: start_kbps > 0,
|
||||
current_kbps: start_kbps,
|
||||
ceiling_kbps: start_kbps,
|
||||
// The env cap binds the NEGOTIATED ceiling too, not just probe-learned ones. It is
|
||||
// the only lever an Automatic session gives the operator (Automatic is precisely
|
||||
// "no explicit bitrate"), so a start rate above it has to come down rather than
|
||||
// stand as a ceiling the user asked not to reach — see the clamp-down step in
|
||||
// [`on_window`](Self::on_window).
|
||||
ceiling_kbps: start_kbps.min(ceiling_cap_kbps.unwrap_or(u32::MAX)),
|
||||
ceiling_cap_kbps,
|
||||
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
||||
probing: true,
|
||||
@@ -269,14 +338,17 @@ impl BitrateController {
|
||||
short_ack_kbps: 0,
|
||||
short_acks: 0,
|
||||
cap_probe_windows: 0,
|
||||
cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||||
decode_cap_kbps: None,
|
||||
decode_backoff_kbps: 0,
|
||||
decode_cap_probe_windows: 0,
|
||||
decode_cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||||
proven_kbps: 0,
|
||||
bad_windows: 0,
|
||||
clean_windows: 0,
|
||||
last_change: None,
|
||||
unacked: 0,
|
||||
ceiling_ask_kbps: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,8 +393,21 @@ impl BitrateController {
|
||||
self.short_acks = 1;
|
||||
}
|
||||
if self.short_acks >= 2 && self.host_cap_kbps.is_none_or(|c| kbps < c) {
|
||||
// Re-learning a cap we had already lifted means the limit is STANDING,
|
||||
// not the transient the re-probe exists to escape — back its clock off
|
||||
// (see [`CAP_REPROBE_WINDOWS_MIN`]) so a hard encoder ceiling settles
|
||||
// into a slow poll instead of two pointless acks every 12 s. A first
|
||||
// latch starts the clock fast, because that is the case that matters.
|
||||
self.cap_reprobe_after = if self.host_cap_kbps.is_some() {
|
||||
self.cap_reprobe_after
|
||||
.saturating_mul(2)
|
||||
.min(CAP_REPROBE_WINDOWS_MAX)
|
||||
} else {
|
||||
CAP_REPROBE_WINDOWS_MIN
|
||||
};
|
||||
tracing::info!(
|
||||
cap_kbps = kbps,
|
||||
reprobe_after_windows = self.cap_reprobe_after,
|
||||
"adaptive bitrate: host cap learned (encoder ceiling or cadence \
|
||||
refusal) — climbs stop here until it lifts"
|
||||
);
|
||||
@@ -331,9 +416,34 @@ impl BitrateController {
|
||||
}
|
||||
} else {
|
||||
self.short_acks = 0;
|
||||
// GRANTED in full at or above the learned cap: the limit that taught it is
|
||||
// gone, and we have the host's own word for it. Drop the cap outright rather
|
||||
// than keep crawling up in +12.5 % re-probe steps — for a cap latched from a
|
||||
// transient (a host briefly behind cadence) that crawl is the entire
|
||||
// remaining cost of the transient, and it is measured in minutes.
|
||||
if self.host_cap_kbps.is_some_and(|c| kbps >= c) {
|
||||
tracing::info!(
|
||||
granted_kbps = kbps,
|
||||
"adaptive bitrate: host granted a climb at the learned cap — the \
|
||||
limit has lifted, dropping it"
|
||||
);
|
||||
self.host_cap_kbps = None;
|
||||
self.cap_probe_windows = 0;
|
||||
self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.current_kbps = kbps;
|
||||
// The host may run ABOVE our climb ceiling, and be right to: it sends an unsolicited
|
||||
// `BitrateChanged` when a rebuild re-resolves an Automatic rate for what it actually
|
||||
// encodes (a 1080p session mirroring a 4K panel resolves ~3× higher), and that is
|
||||
// the host's own Automatic answer, not a climb we asked for. Let the ceiling follow
|
||||
// — `set_ceiling` only ever raises, and still clamps to the operator's
|
||||
// `PUNKTFUNK_ABR_MAX_MBPS`, which is what must bind here if anything does. Without
|
||||
// this the ceiling stays at the stale negotiated rate and the step-down below
|
||||
// immediately drags the host back off the rate it just chose. A no-op for ordinary
|
||||
// acks: we never request above the effective ceiling in the first place.
|
||||
self.set_ceiling(kbps);
|
||||
}
|
||||
self.unacked = 0;
|
||||
}
|
||||
@@ -343,14 +453,30 @@ impl BitrateController {
|
||||
/// decoder's knee is just as mode-scoped (pixel rate drives both ends of the codec), so
|
||||
/// the decode cap goes with it. The probe-measured `ceiling_kbps` (a LINK property)
|
||||
/// survives.
|
||||
///
|
||||
/// Every rolling BASELINE is mode-scoped too, and for the same reason the encode one always
|
||||
/// was: a mode switch changes what "normal" costs at both ends of the pipe. 4K120 decodes
|
||||
/// and encodes far slower than 1080p60 and puts bigger frames on the wire, so a baseline
|
||||
/// learned under the old mode is a floor the new one clears on its very first window —
|
||||
/// [`DECODE_RISE_US`] is 15 µs-thousands, well inside the gap between those two modes. Left
|
||||
/// standing (only `encode_means` used to be cleared here), the ~30 s it takes
|
||||
/// [`BASELINE_WINDOWS`] to age out is ~30 s of every window scoring bad, which is a ×0.7
|
||||
/// backoff every other window: a switch UP in mode cratered the rate instead of raising it.
|
||||
/// `proven_kbps` goes with them — it is the mark climbs are bounded against, and throughput
|
||||
/// the OLD mode's decoder digested is not evidence about this one. It re-earns itself from
|
||||
/// the next window.
|
||||
pub(crate) fn on_mode_switch(&mut self) {
|
||||
self.host_cap_kbps = None;
|
||||
self.short_acks = 0;
|
||||
self.cap_probe_windows = 0;
|
||||
self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
|
||||
self.decode_cap_kbps = None;
|
||||
self.decode_backoff_kbps = 0;
|
||||
self.decode_cap_probe_windows = 0;
|
||||
self.owd_means.clear();
|
||||
self.decode_means.clear();
|
||||
self.encode_means.clear();
|
||||
self.proven_kbps = 0;
|
||||
}
|
||||
|
||||
/// Feed one report window; returns the rate to request now, if any. `dropped` = frames that
|
||||
@@ -389,22 +515,9 @@ impl BitrateController {
|
||||
return None;
|
||||
}
|
||||
// OWD: compare against the rolling-min baseline of PRIOR windows (so a rising window
|
||||
// doesn't drag its own baseline up), then record it.
|
||||
let owd_bad = match owd_mean_us {
|
||||
Some(mean) => {
|
||||
let bad = self
|
||||
.owd_means
|
||||
.iter()
|
||||
.min()
|
||||
.is_some_and(|&base| mean > base + OWD_RISE_US);
|
||||
if self.owd_means.len() == BASELINE_WINDOWS {
|
||||
self.owd_means.pop_front();
|
||||
}
|
||||
self.owd_means.push_back(mean);
|
||||
bad
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
// doesn't drag its own baseline up), then record it. No severe tier — a standing queue is
|
||||
// congestion evidence, not visible damage, so it always takes the two-window path.
|
||||
let (owd_bad, _) = score_baseline(&mut self.owd_means, owd_mean_us, OWD_RISE_US, i64::MAX);
|
||||
// Decode-stage latency: same rolling-min-baseline treatment as OWD, but measuring the
|
||||
// CLIENT'S decoder rather than the link. A rise means the decoder is backlogging frames —
|
||||
// the bottleneck the network signals are blind to. Marking the window bad both ends slow
|
||||
@@ -412,43 +525,22 @@ impl BitrateController {
|
||||
// the link ceiling) and, sustained, drives the ×0.7 backoff down to the real decode limit.
|
||||
// An excursion far past baseline is SEVERE: the decoder is deep in spike-overload and the
|
||||
// user is watching it — skip the two-window confirmation.
|
||||
let (decode_bad, decode_severe) = match decode_mean_us {
|
||||
Some(mean) => {
|
||||
let base = self.decode_means.iter().min().copied();
|
||||
let bad = base.is_some_and(|b| mean > b + DECODE_RISE_US);
|
||||
let severe = base.is_some_and(|b| mean > b + DECODE_SEVERE_US);
|
||||
if self.decode_means.len() == BASELINE_WINDOWS {
|
||||
self.decode_means.pop_front();
|
||||
}
|
||||
self.decode_means.push_back(mean);
|
||||
(bad, severe)
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
let (decode_bad, decode_severe) = score_baseline(
|
||||
&mut self.decode_means,
|
||||
decode_mean_us,
|
||||
DECODE_RISE_US,
|
||||
DECODE_SEVERE_US,
|
||||
);
|
||||
// Host-encode latency: the same rolling-min-baseline treatment, measuring the HOST'S
|
||||
// encoder — the compute-knee down-driver (see [`ENCODE_RISE_US`]). This is the only
|
||||
// signal that can push an already-too-high rate back under the knee: the host refuses
|
||||
// further climbs while behind cadence, but nothing else ever DESCENDS on a clean LAN.
|
||||
let (encode_bad, encode_severe) = match encode_mean_us {
|
||||
Some(mean) => {
|
||||
let base = self.encode_means.iter().min().copied();
|
||||
let bad = base.is_some_and(|b| mean > b + ENCODE_RISE_US);
|
||||
let severe = base.is_some_and(|b| mean > b + ENCODE_SEVERE_US);
|
||||
if self.encode_means.len() == BASELINE_WINDOWS {
|
||||
self.encode_means.pop_front();
|
||||
}
|
||||
self.encode_means.push_back(mean);
|
||||
(bad, severe)
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably
|
||||
// digestible (decode latency stayed flat while it was carried). Loss doesn't disqualify —
|
||||
// the bytes that DID arrive still went through the decoder; what loss means for the rate
|
||||
// is the bad/severe machinery's business.
|
||||
if !decode_bad && actual_kbps > self.proven_kbps {
|
||||
self.proven_kbps = actual_kbps;
|
||||
}
|
||||
let (encode_bad, encode_severe) = score_baseline(
|
||||
&mut self.encode_means,
|
||||
encode_mean_us,
|
||||
ENCODE_RISE_US,
|
||||
ENCODE_SEVERE_US,
|
||||
);
|
||||
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
|
||||
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
|
||||
// any blip — one window is enough. Ordinary congestion (heavy-but-recoverable loss, an
|
||||
@@ -466,6 +558,17 @@ impl BitrateController {
|
||||
|| decode_bad
|
||||
|| encode_bad
|
||||
|| recovery_kf >= RECOVERY_KF_BAD;
|
||||
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably
|
||||
// digestible — the pipeline carried it and NOTHING went wrong while it did. Scored after
|
||||
// the verdict and gated on the whole of it, not on decode alone: the mark never decays, so
|
||||
// one window is permanent authority over how far every later climb may step, and the
|
||||
// windows that overstate delivered throughput are exactly the damaged ones (a stall's
|
||||
// backlog draining in a single window, a flush's queue, the FEC surge that answers a loss
|
||||
// burst). "Loss doesn't disqualify, the bytes still arrived" was true about the bytes and
|
||||
// wrong about the conclusion drawn from them.
|
||||
if !bad && actual_kbps > self.proven_kbps {
|
||||
self.proven_kbps = actual_kbps;
|
||||
}
|
||||
if bad {
|
||||
self.bad_windows += 1;
|
||||
self.clean_windows = 0;
|
||||
@@ -475,16 +578,16 @@ impl BitrateController {
|
||||
self.clean_windows += 1;
|
||||
self.bad_windows = 0;
|
||||
}
|
||||
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS`]): after ~60 s of clean
|
||||
// windows parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a
|
||||
// scene-dependent refusal can't quietly cap the whole session — a still-standing limit
|
||||
// just re-latches from the next pair of short acks, at zero encoder cost.
|
||||
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS_MIN`]): after a clean run
|
||||
// parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a scene-dependent
|
||||
// refusal can't quietly cap the whole session — a still-standing limit just re-latches
|
||||
// from the next pair of short acks, at zero encoder cost, and backs the clock off.
|
||||
if let Some(cap) = self.host_cap_kbps {
|
||||
if bad {
|
||||
self.cap_probe_windows = 0;
|
||||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||||
self.cap_probe_windows += 1;
|
||||
if self.cap_probe_windows >= CAP_REPROBE_WINDOWS {
|
||||
if self.cap_probe_windows >= self.cap_reprobe_after {
|
||||
self.cap_probe_windows = 0;
|
||||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||||
if lifted > cap {
|
||||
@@ -508,7 +611,7 @@ impl BitrateController {
|
||||
self.decode_cap_probe_windows = 0;
|
||||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||||
self.decode_cap_probe_windows += 1;
|
||||
if self.decode_cap_probe_windows >= CAP_REPROBE_WINDOWS {
|
||||
if self.decode_cap_probe_windows >= self.decode_cap_reprobe_after {
|
||||
self.decode_cap_probe_windows = 0;
|
||||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||||
if lifted > cap {
|
||||
@@ -538,18 +641,42 @@ impl BitrateController {
|
||||
// knee. One event never latches (a spurious flush must stay a one-off), and a
|
||||
// backoff without decode evidence in between breaks the streak — whatever it saw,
|
||||
// it wasn't the same knee.
|
||||
if decode_severe || flushed {
|
||||
// A bare flush counts as decode evidence only where the decode signal can't speak
|
||||
// for itself. On an embedder that reports decode latency, a flush with FLAT decode
|
||||
// is a network event (a stall, a clock step) that drained a queue the decoder was
|
||||
// keeping up with — teaching a "decoder knee" from it caps the session on the wrong
|
||||
// end of the pipe. Where the signal is absent the old reading stands: the flush is
|
||||
// the only decoder-saturation evidence there is.
|
||||
let decode_evidence =
|
||||
decode_severe || (flushed && (decode_bad || decode_mean_us.is_none()));
|
||||
if decode_evidence {
|
||||
let rate = self.current_kbps;
|
||||
let similar = self.decode_backoff_kbps > 0
|
||||
&& rate.abs_diff(self.decode_backoff_kbps)
|
||||
<= self.decode_backoff_kbps / DECODE_CAP_SIMILAR_DIV;
|
||||
if similar && self.decode_cap_kbps.is_none_or(|c| rate < c) {
|
||||
// Latch just UNDER the rate that choked, not at it: the knee is the rate the
|
||||
// decoder could not hold, so a cap sitting exactly on it authorizes climbing
|
||||
// straight back into the failure — the sawtooth the cap exists to end, merely
|
||||
// slower. One sixteenth is inside the ±1/8 band the pair had to agree within,
|
||||
// so it costs nothing the evidence actually established.
|
||||
let knee = rate.saturating_sub(rate / 16).max(self.floor_kbps);
|
||||
if similar && self.decode_cap_kbps.is_none_or(|c| knee < c) {
|
||||
// Same standing-vs-transient backoff as the host cap.
|
||||
self.decode_cap_reprobe_after = if self.decode_cap_kbps.is_some() {
|
||||
self.decode_cap_reprobe_after
|
||||
.saturating_mul(2)
|
||||
.min(CAP_REPROBE_WINDOWS_MAX)
|
||||
} else {
|
||||
CAP_REPROBE_WINDOWS_MIN
|
||||
};
|
||||
tracing::info!(
|
||||
cap_kbps = rate,
|
||||
cap_kbps = knee,
|
||||
choked_at_kbps = rate,
|
||||
reprobe_after_windows = self.decode_cap_reprobe_after,
|
||||
"adaptive bitrate: decode cap learned (decoder knee) — climbs stop \
|
||||
here until it lifts"
|
||||
);
|
||||
self.decode_cap_kbps = Some(rate.max(self.floor_kbps));
|
||||
self.decode_cap_kbps = Some(knee);
|
||||
self.decode_cap_probe_windows = 0;
|
||||
}
|
||||
self.decode_backoff_kbps = rate;
|
||||
@@ -574,6 +701,23 @@ impl BitrateController {
|
||||
.ceiling_kbps
|
||||
.min(self.host_cap_kbps.unwrap_or(u32::MAX))
|
||||
.min(self.decode_cap_kbps.unwrap_or(u32::MAX));
|
||||
// Above the ceiling with nothing wrong: the session negotiated a rate the operator's
|
||||
// `PUNKTFUNK_ABR_MAX_MBPS` forbids (no congestion signal will ever find this — the link
|
||||
// is fine, the cap is a policy). Step straight to it rather than sitting above a limit
|
||||
// the user set, and never below the floor. Asked ONCE per distinct target: if the host
|
||||
// answers with something higher it has told us it cannot go there (its own floor, an
|
||||
// encoder minimum), and repeating the ask every cooldown would buy nothing but a
|
||||
// reconfigure each time.
|
||||
let ceiling_target = eff_ceiling.max(self.floor_kbps);
|
||||
if self.current_kbps > ceiling_target && self.ceiling_ask_kbps != ceiling_target {
|
||||
tracing::info!(
|
||||
from_kbps = self.current_kbps,
|
||||
to_kbps = ceiling_target,
|
||||
"adaptive bitrate: session rate is above the configured ceiling — stepping down"
|
||||
);
|
||||
self.ceiling_ask_kbps = ceiling_target;
|
||||
return self.request(ceiling_target, now);
|
||||
}
|
||||
let cap = eff_ceiling
|
||||
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
|
||||
if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps {
|
||||
@@ -602,6 +746,17 @@ impl BitrateController {
|
||||
// request just recomputes from the same base next time (and counts toward MAX_UNACKED).
|
||||
Some(kbps)
|
||||
}
|
||||
|
||||
/// The decision [`on_window`](Self::on_window) returned never reached the wire (the control
|
||||
/// queue was full). Undo the request's bookkeeping: [`MAX_UNACKED`] exists to detect a HOST
|
||||
/// that doesn't answer, and counting a message we never sent toward it retires the
|
||||
/// controller for the session — with a log line blaming an "older host" that is not what
|
||||
/// happened. Clearing the pending request also keeps a later unsolicited ack from being
|
||||
/// judged short against a rate we never asked for.
|
||||
pub(crate) fn on_request_dropped(&mut self) {
|
||||
self.unacked = self.unacked.saturating_sub(1);
|
||||
self.last_requested_kbps = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1110,14 +1265,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn decode_latency_caps_the_slow_start_climb() {
|
||||
// A fat link (probe measured ~300 Mbps) but a decoder that saturates around the start rate.
|
||||
// A fat link (probe measured ~300 Mbps) but a decoder that saturates below it.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
c.set_ceiling(300_000);
|
||||
let start = Instant::now();
|
||||
// First clean window (decoder fine at 20 Mbps) → slow start doubles to 40.
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 0),
|
||||
// Slow start doubles while the decoder keeps up, and the first BASELINE_MIN_WINDOWS of
|
||||
// those windows are what teach the decode baseline (one sample is not a floor).
|
||||
let mut last = 0;
|
||||
for i in 0..BASELINE_MIN_WINDOWS as u32 {
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, i * 2),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
@@ -1125,16 +1282,18 @@ mod tests {
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(40_000)
|
||||
);
|
||||
c.on_ack(40_000);
|
||||
// At 40 Mbps the decoder starts backing up (30 ms over baseline): the window is bad, so the
|
||||
// climb stops here instead of doubling on toward the 300 Mbps link ceiling…
|
||||
0,
|
||||
) {
|
||||
last = k;
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
assert_eq!(last, 300_000, "slow start should reach the probed ceiling");
|
||||
// Now the decoder starts backing up (30 ms over the learned baseline): the window is bad,
|
||||
// so the climb stops instead of parking at the link ceiling…
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 2),
|
||||
ticks(start, 20),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
@@ -1146,11 +1305,11 @@ mod tests {
|
||||
),
|
||||
None
|
||||
);
|
||||
// …and a second backed-up window backs the rate off, settling at the decode limit rather
|
||||
// …and a second backed-up window backs the rate off toward the real decode limit rather
|
||||
// than choking the decoder at the link ceiling (the reported bug).
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
ticks(start, 22),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
@@ -1160,7 +1319,54 @@ mod tests {
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(28_000)
|
||||
Some(210_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_calm_window_is_not_a_baseline() {
|
||||
// The ratchet this guard exists to stop: our own decrease CLEARS the encode baseline, so
|
||||
// it re-seeds from whatever the next window happens to be. If that window is calm, the
|
||||
// ordinary content variance that follows reads as a rise, backs off, clears again — all
|
||||
// the way to the floor on a link that was never the problem. A single sample must not
|
||||
// arm the signal.
|
||||
let mut c = BitrateController::new(100_000);
|
||||
let start = Instant::now();
|
||||
// One calm 3 ms encode window, then windows 9 ms above it: far past ENCODE_RISE_US, and
|
||||
// sustained — yet no baseline exists to judge them against yet.
|
||||
for i in 0..BASELINE_MIN_WINDOWS as u32 {
|
||||
let mean = if i == 0 { 3_000 } else { 12_000 };
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(mean),
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
None,
|
||||
"window {i} fired off a baseline of fewer than {BASELINE_MIN_WINDOWS} samples"
|
||||
);
|
||||
}
|
||||
// With a real baseline (min 3 ms over 4 windows) the signal works exactly as before: a
|
||||
// sustained rise past it still backs the rate off.
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 8),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(20_000),
|
||||
1_000_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(70_000)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1385,8 +1591,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn learned_cap_reprobes_after_a_sustained_clean_run() {
|
||||
// A cadence-refusal cap is scene evidence, not a spec limit: after ~60 s parked clean
|
||||
// at the cap, lift one step so a one-time heavy scene can't cap the session forever. A
|
||||
// A cadence-refusal cap is scene evidence, not a spec limit: after a clean run parked at
|
||||
// the cap, lift one step so a one-time heavy scene can't cap the session forever. A
|
||||
// still-standing limit just re-latches from the next short-ack pair, at zero cost.
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
@@ -1396,7 +1602,10 @@ mod tests {
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
for i in 0..CAP_REPROBE_WINDOWS {
|
||||
// The FIRST re-probe is the fast one — a transient refusal must not cost the session
|
||||
// minutes to escape.
|
||||
assert_eq!(c.cap_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
|
||||
for i in 0..CAP_REPROBE_WINDOWS_MIN {
|
||||
let _ = c.on_window(
|
||||
ticks(start, 20 + i),
|
||||
0,
|
||||
@@ -1412,6 +1621,130 @@ mod tests {
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transient_refusal_does_not_pin_the_session() {
|
||||
// The field failure this whole cap-escape change exists for. A host that escalates its
|
||||
// capture/encode pipeline once — a startup hitch is enough — used to refuse every climb
|
||||
// for the rest of the session; the client latched that refusal as a cap, at whatever
|
||||
// rate slow start had reached, which is routinely the 20 Mbps default. Escaping cost
|
||||
// +12.5 % per ~60 s: north of twenty minutes to reach a 300 Mbps link ceiling, which the
|
||||
// user experiences as "Automatic is broken".
|
||||
let mut c = BitrateController::new(20_000);
|
||||
c.set_ceiling(300_000); // the startup probe measured a fat link
|
||||
let start = Instant::now();
|
||||
let mut tick = 0u32;
|
||||
let mut windows_pinned = 0u32;
|
||||
// Two refused climbs at the same rate → the cap latches at 20 Mbps.
|
||||
for _ in 0..2 {
|
||||
let k = run_clean(&mut c, start, tick, 4).expect("slow start should ask to climb");
|
||||
tick += 4;
|
||||
assert!(k > 20_000);
|
||||
c.on_ack(20_000); // "behind cadence — held at the current rate"
|
||||
}
|
||||
assert_eq!(c.host_cap_kbps, Some(20_000));
|
||||
// The host recovers immediately (its bucket drains; the escalation bought the headroom
|
||||
// it was for), but the client has no way to know that except by asking again. Drive
|
||||
// clean windows and grant whatever it asks for.
|
||||
while c.current_kbps < 150_000 && windows_pinned < 400 {
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, tick),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
c.on_ack(k);
|
||||
}
|
||||
tick += 1;
|
||||
windows_pinned += 1;
|
||||
}
|
||||
assert!(
|
||||
c.current_kbps >= 150_000,
|
||||
"still pinned at {} after {windows_pinned} windows",
|
||||
c.current_kbps
|
||||
);
|
||||
// ~750 ms a window: this must be tens of seconds, not the old tens of minutes.
|
||||
assert!(
|
||||
windows_pinned <= 40,
|
||||
"took {windows_pinned} windows (~{} s) to escape a transient refusal",
|
||||
windows_pinned * 3 / 4
|
||||
);
|
||||
// And the disproven cap is gone, not merely nudged upward.
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_retarget_above_the_ceiling_raises_it() {
|
||||
// The host sends an unsolicited `BitrateChanged` when a rebuild re-resolves an Automatic
|
||||
// rate for what it ACTUALLY encodes — a 1080p session mirroring a 4K panel resolves far
|
||||
// above the negotiated rate. That is the host's own Automatic answer, so the climb
|
||||
// ceiling has to follow it; otherwise the ceiling stays stale and the step-down drags
|
||||
// the host straight back off the rate it just chose.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
assert_eq!(c.ceiling_kbps, 20_000);
|
||||
c.on_ack(60_000); // unsolicited: no request was outstanding
|
||||
assert_eq!(c.current_kbps, 60_000);
|
||||
assert_eq!(c.ceiling_kbps, 60_000);
|
||||
let start = Instant::now();
|
||||
// No step-down, and no spurious re-target of any kind.
|
||||
assert_eq!(run_clean(&mut c, start, 0, 4), None);
|
||||
// The operator's cap still outranks it — that is the one thing that must bind here.
|
||||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||||
c.on_ack(60_000);
|
||||
assert_eq!(c.ceiling_kbps, 50_000);
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(50_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standing_cap_backs_its_reprobe_clock_off() {
|
||||
// The other half of the re-probe: an encoder's real codec ceiling (794 Mbps, L6.2)
|
||||
// re-teaches itself every time the lift is tried. Escaping fast is right for a
|
||||
// transient and pointless here, so each re-learn doubles the interval — a hard limit
|
||||
// settles into a slow poll instead of two acks every 12 s for the whole session.
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.cap_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
|
||||
// Each round: park clean at the cap until it re-probes upward, then have the host refuse
|
||||
// the lift at the same value again. That is a STANDING limit, so the clock doubles.
|
||||
let mut tick = 20;
|
||||
for round in 0..3 {
|
||||
let before = c.cap_reprobe_after;
|
||||
for _ in 0..before {
|
||||
let _ = c.on_window(
|
||||
ticks(start, tick),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
tick += 1;
|
||||
}
|
||||
let lifted = c.host_cap_kbps.expect("cap should still be latched");
|
||||
assert!(lifted > 794_000, "round {round}: the re-probe never lifted");
|
||||
// The host clamps the lift straight back to its real ceiling.
|
||||
c.last_requested_kbps = Some(lifted);
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
assert_eq!(
|
||||
c.cap_reprobe_after,
|
||||
(before * 2).min(CAP_REPROBE_WINDOWS_MAX)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_encode_latency_rise_backs_off() {
|
||||
// The compute knee: link pristine, client decoder fine — only HOST encode time moves
|
||||
@@ -1593,6 +1926,25 @@ mod tests {
|
||||
assert_eq!(run_clean(&mut c, start, 4, 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_above_the_env_cap_steps_down_to_it_once() {
|
||||
// PUNKTFUNK_ABR_MAX_MBPS is the only lever an Automatic session gives the operator, and
|
||||
// it used to bind only ceilings the PROBE taught — so a session that negotiated a rate
|
||||
// above the cap simply ran above it forever. No congestion signal will ever find that:
|
||||
// the link is fine, the cap is policy.
|
||||
let mut c = BitrateController::with_ceiling_cap(100_000, Some(50_000));
|
||||
assert_eq!(c.ceiling_kbps, 50_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(50_000));
|
||||
// Suppose the host answers HIGHER than asked (its own floor, an encoder minimum): that
|
||||
// is the host saying it cannot go there. Don't re-ask every cooldown forever.
|
||||
c.on_ack(80_000);
|
||||
assert_eq!(run_clean(&mut c, start, 2, 20), None);
|
||||
// A ceiling that MOVES is a new question, and gets asked once more.
|
||||
c.set_ceiling(90_000); // clamped to the 50 Mbps cap → still 50 000, no new ask
|
||||
assert_eq!(run_clean(&mut c, start, 24, 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_latches_after_two_consecutive_decode_severe_backoffs() {
|
||||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||||
@@ -1650,7 +2002,7 @@ mod tests {
|
||||
),
|
||||
Some(350_000)
|
||||
);
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
|
||||
// The backoff applies; from here every climb must stop AT the knee — not the 900 Mbps
|
||||
// link ceiling the old sawtooth kept re-poking.
|
||||
c.on_ack(350_000);
|
||||
@@ -1667,14 +2019,22 @@ mod tests {
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
assert!(k <= 500_000, "climb past the decode cap: {k}");
|
||||
// Never past the cap in force when the decision was made. (A long clean run
|
||||
// legitimately re-probes that cap upward — `decode_cap_reprobes_after_a_
|
||||
// sustained_clean_run` owns that; here the point is that nothing climbs toward
|
||||
// the 900 Mbps LINK ceiling the old sawtooth kept re-poking.)
|
||||
assert!(
|
||||
k <= c.decode_cap_kbps.unwrap(),
|
||||
"climb past the decode cap: {k}"
|
||||
);
|
||||
max_req = max_req.max(k);
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
assert_eq!(max_req, 500_000);
|
||||
assert_eq!(c.current_kbps, 500_000);
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
assert!(
|
||||
max_req < 600_000,
|
||||
"the decode knee stopped binding: climbed to {max_req}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1748,10 +2108,10 @@ mod tests {
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
|
||||
// The host's ack parks the session at the knee (its clamp is authoritative).
|
||||
c.on_ack(500_000);
|
||||
for i in 0..CAP_REPROBE_WINDOWS {
|
||||
c.on_ack(500_000 - 500_000 / 16);
|
||||
for i in 0..CAP_REPROBE_WINDOWS_MIN {
|
||||
let _ = c.on_window(
|
||||
ticks(start, 8 + i),
|
||||
0,
|
||||
@@ -1764,7 +2124,8 @@ mod tests {
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 + 500_000 / 8));
|
||||
let knee = 500_000 - 500_000 / 16;
|
||||
assert_eq!(c.decode_cap_kbps, Some(knee + knee / 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1800,7 +2161,7 @@ mod tests {
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
|
||||
c.on_mode_switch();
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
assert_eq!(c.ceiling_kbps, 900_000);
|
||||
|
||||
@@ -232,7 +232,7 @@ impl DataPump {
|
||||
last_late = st.fec_late_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.bytes_received;
|
||||
last_bytes = st.media_bytes_received;
|
||||
last_report = Instant::now();
|
||||
discard_abr_window = true;
|
||||
flush_in_window = false;
|
||||
@@ -317,11 +317,12 @@ impl DataPump {
|
||||
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
|
||||
);
|
||||
}
|
||||
// The probe's FLAG_PROBE filler landed in `bytes_received` but never reached
|
||||
// the decoder — rebase the ABR window's byte counter past it, or the next
|
||||
// window's "actual throughput" reads as the burst rate and poisons the
|
||||
// controller's proven-throughput high-water mark with the LINK rate.
|
||||
last_bytes = st.bytes_received;
|
||||
// Rebase the ABR window's byte anchor past the burst. (Probe filler is
|
||||
// routed out of `media_bytes_received` at the reassembler, so it can no
|
||||
// longer read as the burst rate on its own — but the anchor still has to
|
||||
// skip the video that landed around the burst under a suppressed report
|
||||
// tick, which would otherwise divide a long span's bytes by one window.)
|
||||
last_bytes = st.media_bytes_received;
|
||||
} else if Instant::now() >= deadline {
|
||||
// The host never answered (a build that ignores ProbeRequest): clear the
|
||||
// stuck-active state so LossReports resume, keep the negotiated ceiling.
|
||||
@@ -454,11 +455,17 @@ impl DataPump {
|
||||
// the next one.
|
||||
let recovery_kf_reqs = pump_recovery_kf.swap(0, Ordering::Relaxed);
|
||||
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
||||
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
||||
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
||||
// semantics (both compare against targets with their own headroom).
|
||||
// the target it was allowed. MEDIA bytes (data-shard payload: no headers, no FEC
|
||||
// parity, no probe filler, no audio), because both consumers compare it against
|
||||
// the ENCODER's target: the utilization gate asks "was the target genuinely
|
||||
// tested?" and the proven mark bounds every later climb. Wire bytes answered a
|
||||
// different question — they rise with the redundancy the host adds in answer to
|
||||
// loss, so the gate read ~25 % high precisely on the links it exists for.
|
||||
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
|
||||
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
|
||||
let actual_kbps = (st
|
||||
.media_bytes_received
|
||||
.wrapping_sub(last_bytes)
|
||||
.saturating_mul(8)
|
||||
/ window_ms) as u32;
|
||||
// A discard window feeds the controller NOTHING — its signals are probe-tail
|
||||
// residue, and one "congestion" verdict here ends slow start for good.
|
||||
@@ -492,7 +499,15 @@ impl DataPump {
|
||||
recovery_kf = recovery_kf_reqs,
|
||||
"adaptive bitrate: requesting encoder re-target"
|
||||
);
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
|
||||
if ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps)).is_err() {
|
||||
// Never reached the control task — tell the controller, or three of
|
||||
// these retire it for the session as "the host never acked".
|
||||
abr.on_request_dropped();
|
||||
tracing::warn!(
|
||||
kbps,
|
||||
"adaptive bitrate: control queue full — re-target dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
flush_in_window = false;
|
||||
last_report = Instant::now();
|
||||
@@ -500,7 +515,7 @@ impl DataPump {
|
||||
last_late = st.fec_late_shards;
|
||||
last_received = st.packets_received;
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.bytes_received;
|
||||
last_bytes = st.media_bytes_received;
|
||||
if pump_perf_on {
|
||||
if let Some(p) = session.take_pump_perf() {
|
||||
let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
|
||||
|
||||
@@ -429,6 +429,13 @@ impl Reassembler {
|
||||
stats
|
||||
.probe_last_arrival_ns
|
||||
.store(now_ns, std::sync::atomic::Ordering::Relaxed);
|
||||
} else if hdr.shard_index < hdr.data_shards {
|
||||
// Media accounting (see `Stats::media_bytes_received`): DATA shards only, payload
|
||||
// only. Stamped at the same routing decision as the probe counters and for the same
|
||||
// reason — the adaptive-bitrate utilization gate compares delivered throughput
|
||||
// against an ENCODER target, so parity, headers and probe filler have no business
|
||||
// in the numerator.
|
||||
StatsCounters::add(&stats.media_bytes_received, shard_bytes as u64);
|
||||
}
|
||||
let win = if is_probe { probe } else { video };
|
||||
win.advance_window(
|
||||
|
||||
@@ -45,6 +45,16 @@ pub struct Stats {
|
||||
/// so a speed-test numerator built from it inherits whatever video was in flight around
|
||||
/// the burst — these keep video out of the probe math. Deliberately NOT mirrored into the
|
||||
/// C-ABI `PunktfunkStats` (probe measurements surface via `ProbeOutcome`).
|
||||
/// Media bytes delivered to the video reassembler: DATA-shard payload only — no packet
|
||||
/// headers, no FEC parity, no probe filler, no audio. This is the rate the encoder's target
|
||||
/// is a promise about, and the only honest thing to compare that target against.
|
||||
/// `bytes_received` counts every accepted datagram, so a "delivered throughput" built from
|
||||
/// it rises with the FEC redundancy the host adds in answer to loss — which meant the
|
||||
/// adaptive-bitrate utilization gate ("did the pipeline actually carry ~the target?") read
|
||||
/// 25 % high exactly on the lossy links it exists for, and the never-decaying
|
||||
/// proven-throughput mark inherited the same inflation. Deliberately NOT mirrored into the
|
||||
/// C-ABI `PunktfunkStats`.
|
||||
pub media_bytes_received: u64,
|
||||
pub probe_packets_received: u64,
|
||||
pub probe_bytes_received: u64,
|
||||
/// First / last probe-packet arrival (monotonic ns, see [`now_monotonic_ns`]; 0 = none
|
||||
@@ -75,6 +85,7 @@ pub struct StatsCounters {
|
||||
pub fec_late_shards: AtomicU64,
|
||||
pub bytes_sent: AtomicU64,
|
||||
pub bytes_received: AtomicU64,
|
||||
pub media_bytes_received: AtomicU64,
|
||||
pub probe_packets_received: AtomicU64,
|
||||
pub probe_bytes_received: AtomicU64,
|
||||
pub probe_first_arrival_ns: AtomicU64,
|
||||
@@ -101,6 +112,7 @@ impl StatsCounters {
|
||||
fec_late_shards: self.fec_late_shards.load(l),
|
||||
bytes_sent: self.bytes_sent.load(l),
|
||||
bytes_received: self.bytes_received.load(l),
|
||||
media_bytes_received: self.media_bytes_received.load(l),
|
||||
probe_packets_received: self.probe_packets_received.load(l),
|
||||
probe_bytes_received: self.probe_bytes_received.load(l),
|
||||
probe_first_arrival_ns: self.probe_first_arrival_ns.load(l),
|
||||
|
||||
@@ -77,6 +77,32 @@ impl LogRing {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
self.push_entry(level.to_string(), target.to_string(), msg, ts_ms);
|
||||
}
|
||||
|
||||
/// Ingest a line that was produced in **another process** — the plugin/script runner, via
|
||||
/// `POST /plugins/logs` (see `mgmt::plugins::ingest_plugin_logs`).
|
||||
///
|
||||
/// Plugins are not host child processes: the runner is a separate bun process that `import()`s
|
||||
/// each plugin in-process, so a plugin's output never passes through this process's `tracing`
|
||||
/// and [`RingLayer`] can't see it. Without this door the console's log page shows nothing about
|
||||
/// the plugins at all, and on Windows nothing else does either — the runner task writes no log
|
||||
/// file, so a failing plugin was diagnosable only by stopping the task and re-running it by
|
||||
/// hand (field report 2026-08-03, the VirtualHere plugin).
|
||||
///
|
||||
/// The caller's `ts_ms` is kept — the line was stamped when it happened, and re-stamping it on
|
||||
/// arrival would collapse a whole batch onto the moment it was flushed. `seq` stays ours: it is
|
||||
/// the cursor for a single ring with several producers, so only the ring can mint it.
|
||||
pub fn push_remote(&self, level: &str, target: &str, msg: &str, ts_ms: u64) {
|
||||
self.push_entry(
|
||||
normalize_level(level).to_string(),
|
||||
target.to_string(),
|
||||
truncate_msg(msg.to_string()),
|
||||
ts_ms,
|
||||
);
|
||||
}
|
||||
|
||||
fn push_entry(&self, level: String, target: String, msg: String, ts_ms: u64) {
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let seq = inner.next_seq;
|
||||
inner.next_seq += 1;
|
||||
@@ -86,8 +112,8 @@ impl LogRing {
|
||||
inner.entries.push_back(LogEntry {
|
||||
seq,
|
||||
ts_ms,
|
||||
level: level.to_string(),
|
||||
target: target.to_string(),
|
||||
level,
|
||||
target,
|
||||
msg,
|
||||
});
|
||||
}
|
||||
@@ -125,6 +151,33 @@ pub fn ring() -> &'static LogRing {
|
||||
RING.get_or_init(LogRing::new)
|
||||
}
|
||||
|
||||
/// Coerce an externally-supplied level to the five the console's filter ranks. Anything else —
|
||||
/// a plugin inventing `NOTICE`, a truncated line, empty — becomes `INFO` rather than being
|
||||
/// rejected: an unfamiliar level is not a reason to drop the operator's diagnostics on the floor,
|
||||
/// and an unranked string would sort as `0` in the console's `RANK` map and hide under every filter.
|
||||
fn normalize_level(level: &str) -> &'static str {
|
||||
match level.trim().to_ascii_uppercase().as_str() {
|
||||
"ERROR" | "FATAL" | "SEVERE" => "ERROR",
|
||||
"WARN" | "WARNING" => "WARN",
|
||||
"DEBUG" => "DEBUG",
|
||||
"TRACE" | "VERBOSE" => "TRACE",
|
||||
_ => "INFO",
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap a message at [`MAX_MSG`], cutting on a char boundary and marking the elision.
|
||||
fn truncate_msg(mut msg: String) -> String {
|
||||
if msg.len() > MAX_MSG {
|
||||
let mut end = MAX_MSG;
|
||||
while !msg.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
msg.truncate(end);
|
||||
msg.push('…');
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// Targets whose DEBUG/TRACE output is steady-state chatter, not diagnostics — left in, they evict
|
||||
/// the entire ring tail: `mdns_sd` DEBUG-logs every multicast packet it can't parse (one chatty
|
||||
/// AirPlay/HomePod device on the LAN floods thousands of entries per hour), and `wasapi` DEBUG-logs
|
||||
@@ -223,15 +276,7 @@ impl FieldFmt {
|
||||
} else {
|
||||
self.msg.push_str(&self.fields);
|
||||
}
|
||||
if self.msg.len() > MAX_MSG {
|
||||
let mut end = MAX_MSG;
|
||||
while !self.msg.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
self.msg.truncate(end);
|
||||
self.msg.push('…');
|
||||
}
|
||||
self.msg
|
||||
truncate_msg(self.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +405,45 @@ mod tests {
|
||||
assert!(page.entries.iter().any(|e| e.target == "mdns_sdx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_entries_keep_their_own_timestamp_and_share_the_cursor() {
|
||||
let ring = LogRing::new();
|
||||
ring.push(&tracing::Level::INFO, "punktfunk_host", "local".into());
|
||||
ring.push_remote("WARN", "plugin:virtualhere", "remote", 1_700_000_000_123);
|
||||
|
||||
let page = ring.since(0, 10);
|
||||
assert_eq!(page.entries.len(), 2);
|
||||
// One sequence across both producers — the console's cursor cannot see two rings.
|
||||
assert_eq!(page.entries[0].seq, 1);
|
||||
assert_eq!(page.entries[1].seq, 2);
|
||||
let remote = &page.entries[1];
|
||||
assert_eq!(remote.level, "WARN");
|
||||
assert_eq!(remote.target, "plugin:virtualhere");
|
||||
assert_eq!(remote.msg, "remote");
|
||||
// Stamped when it happened, not when the batch arrived.
|
||||
assert_eq!(remote.ts_ms, 1_700_000_000_123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_levels_are_coerced_not_rejected() {
|
||||
assert_eq!(normalize_level("error"), "ERROR");
|
||||
assert_eq!(normalize_level(" Warning "), "WARN");
|
||||
assert_eq!(normalize_level("TRACE"), "TRACE");
|
||||
// An unranked level would sort as 0 in the console's filter and hide under every setting.
|
||||
assert_eq!(normalize_level("NOTICE"), "INFO");
|
||||
assert_eq!(normalize_level(""), "INFO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_messages_are_truncated_like_local_ones() {
|
||||
let ring = LogRing::new();
|
||||
ring.push_remote("INFO", "plugin:x", &"ä".repeat(MAX_MSG), 1);
|
||||
let page = ring.since(0, 10);
|
||||
let msg = &page.entries[0].msg;
|
||||
assert!(msg.ends_with('…'));
|
||||
assert!(msg.len() <= MAX_MSG + '…'.len_utf8());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_truncation_keeps_char_boundary() {
|
||||
let f = FieldFmt {
|
||||
|
||||
@@ -253,6 +253,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(plugins::list_plugins))
|
||||
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
|
||||
.routes(routes!(plugins::get_ui_credential))
|
||||
.routes(routes!(plugins::ingest_plugin_logs))
|
||||
.routes(routes!(store::get_catalog))
|
||||
.routes(routes!(store::refresh_catalog))
|
||||
.routes(routes!(store::list_installed))
|
||||
|
||||
@@ -29,6 +29,12 @@ use std::time::{Duration, Instant};
|
||||
/// this tolerates two missed ticks before a plugin drops out of the listing.
|
||||
const LEASE_TTL: Duration = Duration::from_secs(90);
|
||||
|
||||
/// Lines accepted per `POST /plugins/logs`. The runner batches on a short timer, so a batch this
|
||||
/// size means a plugin is logging faster than the ring can usefully hold — the shipper drops its
|
||||
/// own backlog (and says so in a line of its own) rather than letting one chatty plugin evict the
|
||||
/// whole ring in a single request.
|
||||
const MAX_LOG_BATCH: usize = 256;
|
||||
|
||||
// ---------------------------------------------------------------- wire shapes
|
||||
|
||||
/// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request
|
||||
@@ -60,6 +66,26 @@ pub(crate) struct PluginRegistration {
|
||||
pub ui: Option<PluginUi>,
|
||||
}
|
||||
|
||||
/// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct PluginLogLine {
|
||||
/// When the line was produced, unix milliseconds. Kept verbatim — see
|
||||
/// [`crate::log_capture::LogRing::push_remote`].
|
||||
pub ts_ms: u64,
|
||||
/// `ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`.
|
||||
pub level: String,
|
||||
/// Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.
|
||||
/// Surfaced in the console's target column as `plugin:<source>`.
|
||||
pub source: String,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
/// A batch of runner log lines.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct PluginLogBatch {
|
||||
pub entries: Vec<PluginLogLine>,
|
||||
}
|
||||
|
||||
/// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PluginUiPublic {
|
||||
@@ -264,6 +290,26 @@ fn sanitize(s: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The console target for a runner-supplied line: `plugin:<source>`.
|
||||
///
|
||||
/// The source is NOT a [`valid_plugin_id`] — the runner names a unit by its `definePlugin` name
|
||||
/// (`virtualhere`), its package name (`@punktfunk/plugin-virtualhere`), a bare script's file stem,
|
||||
/// or `runner` for its own supervision lines, and all four are worth telling apart in the log. So
|
||||
/// this sanitizes rather than validates: control characters go (a log target is rendered in a
|
||||
/// terminal by `logs download` as readily as in the console), length is capped, and an empty source
|
||||
/// becomes `runner` so a line is never attributed to nothing.
|
||||
fn log_target(source: &str) -> String {
|
||||
let mut s = sanitize(source);
|
||||
if s.is_empty() {
|
||||
s = "runner".into();
|
||||
}
|
||||
// Cap on CHARS, not bytes — truncating a multi-byte name mid-sequence would panic.
|
||||
if s.chars().count() > 64 {
|
||||
s = s.chars().take(64).collect();
|
||||
}
|
||||
format!("plugin:{s}")
|
||||
}
|
||||
|
||||
/// Validate a registration body into the internal [`Valid`] form, or a human-readable reason.
|
||||
fn validate(reg: PluginRegistration) -> Result<Valid, String> {
|
||||
let title = sanitize(®.title);
|
||||
@@ -367,6 +413,63 @@ pub(crate) async fn register_plugin(
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
/// Ingest runner log lines
|
||||
///
|
||||
/// The plugin/script runner ships its output here so the console's **Logs** page can show it.
|
||||
///
|
||||
/// Plugins are not host child processes — the runner is a separate `bun` process that `import()`s
|
||||
/// each plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and
|
||||
/// before this endpoint the console's log page could not show a single plugin line. On Linux the
|
||||
/// fallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no
|
||||
/// log file at all, so a failing plugin was diagnosable only by stopping the scheduled task and
|
||||
/// re-running the runner by hand. Both are shell access on the host box, which is exactly what the
|
||||
/// console exists to avoid.
|
||||
///
|
||||
/// Lines land in the same ring as the host's own, sharing one `seq` cursor, targeted
|
||||
/// `plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/plugins/logs",
|
||||
tag = "plugins",
|
||||
operation_id = "ingestPluginLogs",
|
||||
request_body = PluginLogBatch,
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Lines ingested"),
|
||||
(status = BAD_REQUEST, description = "Batch too large", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn ingest_plugin_logs(ApiJson(batch): ApiJson<PluginLogBatch>) -> Response {
|
||||
if batch.entries.len() > MAX_LOG_BATCH {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("at most {MAX_LOG_BATCH} entries per batch"),
|
||||
);
|
||||
}
|
||||
for line in batch.entries {
|
||||
crate::log_capture::ring().push_remote(
|
||||
&line.level,
|
||||
&log_target(&line.source),
|
||||
&sanitize_msg(&line.msg),
|
||||
line.ts_ms,
|
||||
);
|
||||
}
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
/// Strip control characters from an ingested message, keeping tabs.
|
||||
///
|
||||
/// Same reasoning as [`sanitize`], one exception wider: a plugin's messages routinely carry a
|
||||
/// stack trace or a vendor CLI's output, and an embedded newline would let one line forge several
|
||||
/// in the downloaded log file. Tabs survive because they are load-bearing in that kind of output.
|
||||
fn sanitize_msg(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c == '\t' || !c.is_control() { c } else { ' ' })
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// List registered plugins
|
||||
///
|
||||
/// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry
|
||||
|
||||
@@ -620,6 +620,23 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() {
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
|
||||
// Log ingest. This is the ONLY token the scripting runner holds (on Windows its LocalService
|
||||
// principal cannot even read the admin one), so if this lane ever stopped reaching this route
|
||||
// the console's plugin logs would go quiet with nothing else failing — pin it here rather than
|
||||
// rely on `plugin_may_access`'s denylist continuing to not match `/plugins/logs`.
|
||||
let body = serde_json::json!({"entries": [{
|
||||
"ts_ms": 1_700_000_000_000u64,
|
||||
"level": "INFO",
|
||||
"source": "virtualhere",
|
||||
"msg": "hello from the runner",
|
||||
}]});
|
||||
let req = axum::http::Request::post("/api/v1/plugins/logs")
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer plugin-secret")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(send(&app, req).await.0, StatusCode::NO_CONTENT);
|
||||
|
||||
// The carve-outs answer 403 (authenticated but not authorized), not 401.
|
||||
for (method, path) in [
|
||||
(Method::GET, "/api/v1/hooks"),
|
||||
@@ -972,6 +989,59 @@ async fn plugin_registry_roundtrip() {
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// Runner log ingest: lines reach the same ring `GET /logs` serves, tagged so the console can tell
|
||||
/// them from the host's own, and one chatty plugin can't evict the ring in a single request.
|
||||
#[tokio::test]
|
||||
async fn plugin_log_ingest_lands_in_the_ring() {
|
||||
let app = test_app(test_state(), None);
|
||||
let marker = "vh-ingest-marker-3f9a";
|
||||
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_json(
|
||||
"/api/v1/plugins/logs",
|
||||
serde_json::json!({"entries": [
|
||||
{"ts_ms": 1_700_000_000_123u64, "level": "warn", "source": "virtualhere", "msg": marker},
|
||||
// No source: attributed to the runner rather than to nothing.
|
||||
{"ts_ms": 1_700_000_000_124u64, "level": "NOTICE", "source": "", "msg": "orphan"},
|
||||
]}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, body) = send(&app, get_req("/api/v1/logs?limit=1000")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let entries = body["entries"].as_array().unwrap();
|
||||
|
||||
let mine = entries
|
||||
.iter()
|
||||
.find(|e| e["msg"] == marker)
|
||||
.expect("ingested line is served by GET /logs");
|
||||
// `plugin:` is what the console's Host/Plugins filter keys on.
|
||||
assert_eq!(mine["target"], "plugin:virtualhere");
|
||||
// Lowercase in, canonical out — the console ranks these five and nothing else.
|
||||
assert_eq!(mine["level"], "WARN");
|
||||
// Stamped when the line happened, not when the batch arrived.
|
||||
assert_eq!(mine["ts_ms"], 1_700_000_000_123u64);
|
||||
|
||||
let orphan = entries.iter().find(|e| e["msg"] == "orphan").unwrap();
|
||||
assert_eq!(orphan["target"], "plugin:runner");
|
||||
// An unranked level would sort as 0 in the console's filter and hide under every setting.
|
||||
assert_eq!(orphan["level"], "INFO");
|
||||
|
||||
// An oversized batch is refused whole rather than half-ingested.
|
||||
let big: Vec<serde_json::Value> = (0..300)
|
||||
.map(|i| serde_json::json!({"ts_ms": 1u64, "level": "INFO", "source": "x", "msg": format!("f{i}")}))
|
||||
.collect();
|
||||
let (status, _) = send(
|
||||
&app,
|
||||
post_json("/api/v1/plugins/logs", serde_json::json!({"entries": big})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
/// The OpenAPI document lists every route with a unique operationId (codegen relies
|
||||
/// on both), and the checked-in copy is current.
|
||||
#[test]
|
||||
|
||||
@@ -1073,6 +1073,17 @@ async fn serve_session(
|
||||
// accepted ack as "the active mode is now X" and fixes itself; old clients just log it.
|
||||
let (reconfig_result_tx, reconfig_result_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<Reconfigured>();
|
||||
// Unsolicited bitrate re-target, data plane → control task (the `reconfig_result_tx` pattern
|
||||
// again, for the same reason). A pipeline rebuild can RE-RESOLVE an Automatic rate — most
|
||||
// visibly when the source delivers a different size than the session negotiated, e.g. a
|
||||
// client that asked for 1080p mirroring a 4K panel — and that number is what everything
|
||||
// downstream reasons about: the send pacer, the console, and the base a `SetBitrate` ack is
|
||||
// measured against. The client's copy only ever moved on an ack, so it stayed on the
|
||||
// negotiated rate while the host encoded at another one, and the ABR's first climb computed
|
||||
// from that stale base asked for LESS than the host was already sending — a re-target
|
||||
// downward, with the rebuild it costs. Tell the client instead; `BitrateChanged` already
|
||||
// means exactly this and old clients already handle one arriving unprompted.
|
||||
let (retarget_tx, retarget_rx) = tokio::sync::mpsc::unbounded_channel::<u32>();
|
||||
// Cursor-forward bridge (M2): the encode loop diffs each frame's cursor serial and hands
|
||||
// changed SHAPES here; the control task (the control stream's sole writer) sends them.
|
||||
// Same shape as `probe_result_tx`. Wired even when the channel wasn't negotiated — it
|
||||
@@ -1133,6 +1144,7 @@ async fn serve_session(
|
||||
probe_tx,
|
||||
probe_result_rx,
|
||||
reconfig_result_rx,
|
||||
retarget_rx,
|
||||
cursor_shape_rx,
|
||||
cursor_client_draws,
|
||||
clip_enabled,
|
||||
@@ -1579,6 +1591,7 @@ async fn serve_session(
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
reconfig_result_tx,
|
||||
retarget_tx,
|
||||
fec_target: fec_target_dp,
|
||||
phase: phase_ctl,
|
||||
conn: conn_stream,
|
||||
|
||||
@@ -40,6 +40,9 @@ pub(super) async fn run(
|
||||
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
|
||||
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
|
||||
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
|
||||
// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
|
||||
// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
|
||||
mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
|
||||
mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
|
||||
cursor_client_draws: Arc<AtomicBool>,
|
||||
clip_enabled: Arc<AtomicBool>,
|
||||
@@ -338,6 +341,27 @@ pub(super) async fn run(
|
||||
None => clip_offer_closed = true,
|
||||
}
|
||||
}
|
||||
retarget = retarget_rx.recv() => {
|
||||
// A pipeline rebuild re-resolved the Automatic rate (see `retarget_tx`). Same
|
||||
// message the `SetBitrate` path answers with — the client's controller treats
|
||||
// any `BitrateChanged` as authoritative for what the encoder now targets, which
|
||||
// is exactly right here: it IS what the encoder now targets, we just weren't
|
||||
// asked. PyroWave reaches this too, and should: its rate is pinned against
|
||||
// mid-stream RETARGETS, but a mode switch legitimately re-resolves the pin
|
||||
// (~1.6 bpp for the new pixel rate) and the client's live-rate display is
|
||||
// otherwise stuck on the old one. Its controller is off, so nothing acts on it.
|
||||
let Some(kbps) = retarget else { break }; // data plane gone
|
||||
tracing::info!(
|
||||
kbps,
|
||||
"encoder re-targeted by a pipeline rebuild — telling the client"
|
||||
);
|
||||
if io::write_msg(&mut ctrl_send, &BitrateChanged { bitrate_kbps: kbps }.encode())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
correction = reconfig_result_rx.recv() => {
|
||||
// H2 rollback/correction ack: the data plane reports the mode ACTUALLY live
|
||||
// after a rebuild that failed (stayed at the old mode) or that the backend
|
||||
|
||||
@@ -1214,6 +1214,9 @@ pub(super) struct SessionContext {
|
||||
/// `Reconfigured { accepted: true, mode: <actually live> }` when a rebuild failed (stayed at
|
||||
/// the old mode) or the backend honored a different refresh than requested.
|
||||
pub(super) reconfig_result_tx: tokio::sync::mpsc::UnboundedSender<Reconfigured>,
|
||||
/// Host-initiated bitrate re-target → control task → the client's `BitrateChanged`. Fired
|
||||
/// by [`adopt_built_bitrate`] when a rebuild lands on a rate the client wasn't told about.
|
||||
pub(super) retarget_tx: tokio::sync::mpsc::UnboundedSender<u32>,
|
||||
/// Adaptive-FEC target the control task updates from the client's loss reports.
|
||||
pub(super) fec_target: Arc<AtomicU8>,
|
||||
/// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream).
|
||||
@@ -1397,6 +1400,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
reconfig_result_tx,
|
||||
retarget_tx,
|
||||
fec_target,
|
||||
conn,
|
||||
timing_conn,
|
||||
@@ -1597,7 +1601,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
) = pipe;
|
||||
// The encoder may have opened at a re-resolved rate (a mirrored head delivering a size this
|
||||
// session never negotiated). Adopt it before anything downstream reads `bitrate_kbps`.
|
||||
adopt_built_bitrate(&mut bitrate_kbps, built_bitrate, &live_bitrate);
|
||||
adopt_built_bitrate(
|
||||
&mut bitrate_kbps,
|
||||
built_bitrate,
|
||||
&live_bitrate,
|
||||
&retarget_tx,
|
||||
);
|
||||
|
||||
// Capture is live — launch the requested title so it renders onto the streamed output and
|
||||
// grabs focus. Windows spawns the library id into the interactive user session; Linux spawns
|
||||
@@ -2037,7 +2046,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// The new compositor may deliver a different size than the old one did (a
|
||||
// Game→Desktop switch onto a mirrored 4K panel is exactly that), so adopt
|
||||
// the rate the rebuilt encoder actually opened at.
|
||||
adopt_built_bitrate(&mut bitrate_kbps, new_bitrate, &live_bitrate);
|
||||
adopt_built_bitrate(
|
||||
&mut bitrate_kbps,
|
||||
new_bitrate,
|
||||
&live_bitrate,
|
||||
&retarget_tx,
|
||||
);
|
||||
vd = new_vd;
|
||||
compositor = sw.compositor;
|
||||
next = std::time::Instant::now();
|
||||
@@ -2177,7 +2191,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
};
|
||||
if rebuilt {
|
||||
adopt_built_bitrate(&mut bitrate_kbps, built_bitrate, &live_bitrate);
|
||||
adopt_built_bitrate(
|
||||
&mut bitrate_kbps,
|
||||
built_bitrate,
|
||||
&live_bitrate,
|
||||
&retarget_tx,
|
||||
);
|
||||
cur_mode = new_mode;
|
||||
next = std::time::Instant::now();
|
||||
// H2/H3: the backend may have honored a different mode than requested — KWin caps
|
||||
@@ -2306,6 +2325,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
);
|
||||
if applied_kbps < new_kbps {
|
||||
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||
// The control task already acked the client with its own resolve, which was
|
||||
// higher than what the encoder took. Correct it, or the controller climbs
|
||||
// from a rate the encoder never ran at until its NEXT request happens to be
|
||||
// pre-clamped by the ceiling we just stored.
|
||||
let _ = retarget_tx.send(applied_kbps);
|
||||
}
|
||||
if applied_kbps < bitrate_kbps {
|
||||
// Down-step: the behind-cadence backlog was scored against the old,
|
||||
@@ -2356,6 +2380,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
enc = new_enc;
|
||||
if applied_kbps < new_kbps {
|
||||
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||
// As in the in-place arm: the ack the client already has promises
|
||||
// more than the fresh encoder accepted — correct it.
|
||||
let _ = retarget_tx.send(applied_kbps);
|
||||
}
|
||||
bitrate_kbps = applied_kbps;
|
||||
live_bitrate.store(applied_kbps, Ordering::Relaxed);
|
||||
@@ -2797,7 +2824,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// A capture-loss rebuild can land on a different source than it lost (this loop
|
||||
// re-detects the session every cycle, precisely so it can follow a switch), so the
|
||||
// delivered size — and with it an Automatic rate — may have changed under us.
|
||||
adopt_built_bitrate(&mut bitrate_kbps, new_bitrate, &live_bitrate);
|
||||
adopt_built_bitrate(&mut bitrate_kbps, new_bitrate, &live_bitrate, &retarget_tx);
|
||||
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
|
||||
last_forced_idr = Some(std::time::Instant::now()); // anchor the IDR cooldown from the rebuild
|
||||
next = std::time::Instant::now();
|
||||
@@ -3397,11 +3424,22 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
};
|
||||
let escalated = cur_depth > 1 || pipelined_active || deescalating;
|
||||
// Export "encode can't hold cadence" for the control task's climb refusal.
|
||||
// An escalated session stays flagged even with the bucket drained: its climb
|
||||
// headroom is spent, and letting climbs resume would saw against the
|
||||
// An escalated session is held to a stricter standard — ANY net behind-frame
|
||||
// keeps it flagged, where an unescalated one is given the full bucket — because
|
||||
// its climb headroom really is partly spent and a climb would saw against the
|
||||
// escalation and starve the de-escalation clean run below.
|
||||
//
|
||||
// But being escalated cannot flag it BY ITSELF, which is what this used to do.
|
||||
// The client can't tell a transient refusal from an encoder's real ceiling: two
|
||||
// identical short acks latch a cap, so a session that escalated once — the
|
||||
// bucket needs ~20 net misses, which a startup hitch supplies while the ABR is
|
||||
// still in slow start at the 20 Mbps default — got pinned there, and stayed
|
||||
// pinned long after the escalation had bought back the headroom it was for.
|
||||
// Escalating exists precisely so cadence CAN be held; once it is (bucket
|
||||
// drained, every frame on time), refusing climbs is refusing the thing that
|
||||
// worked.
|
||||
cadence_degraded.store(
|
||||
escalated || behind_score >= DEPTH_DEGRADE,
|
||||
encode_behind_cadence(escalated, behind_score, DEPTH_DEGRADE),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
if deescalating {
|
||||
@@ -4068,13 +4106,42 @@ impl PaceBudget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the encoder currently fail to hold the frame cadence? Exported to the control task, which
|
||||
/// refuses bitrate CLIMBS while it is true (descents always pass — they are the cure).
|
||||
///
|
||||
/// `escalated` = the session has already spent an adaptive-depth / pipelined-retrieve step to buy
|
||||
/// headroom; `behind_score` is the leaky bucket of frames whose work overran the cadence deadline.
|
||||
/// An escalated session is judged strictly — ANY net behind-frame keeps it flagged — but being
|
||||
/// escalated does not flag it on its own. That distinction is the whole point: the client cannot
|
||||
/// tell a transient refusal from an encoder's hard ceiling (two identical short acks latch a cap),
|
||||
/// so "escalated ⇒ degraded, permanently" pinned Automatic sessions at whatever rate they happened
|
||||
/// to hold when a startup hitch escalated them — routinely the 20 Mbps default, while slow start
|
||||
/// had barely begun. Escalation exists so cadence CAN be held; once it is, refusing climbs refuses
|
||||
/// the thing that worked.
|
||||
fn encode_behind_cadence(escalated: bool, behind_score: u32, degrade_at: u32) -> bool {
|
||||
behind_score >= degrade_at || (escalated && behind_score > 0)
|
||||
}
|
||||
|
||||
/// Adopt the rate a freshly built pipeline's encoder was actually opened at.
|
||||
///
|
||||
/// The session's own `bitrate_kbps` is the number every later decision reads — the ABR controller's
|
||||
/// climb base, the console's sample, what a `SetBitrate` ack is measured against — so letting it
|
||||
/// disagree with the live encoder means each of those reasons about a stream that doesn't exist.
|
||||
/// Silent when nothing changed, which is the overwhelmingly common case.
|
||||
fn adopt_built_bitrate(current: &mut u32, built: u32, live: &Arc<AtomicU32>) {
|
||||
///
|
||||
/// The client keeps its OWN copy of that number, and it used to move only on an ack — so a
|
||||
/// rebuild that re-resolved an Automatic rate (`build_pipeline` does, whenever the source
|
||||
/// delivers a size the session did not negotiate) left the two disagreeing for the rest of the
|
||||
/// session. The ABR's next climb then computed from the stale base and asked for a rate BELOW
|
||||
/// what the host was already sending: a re-target downward, paying an encoder rebuild to get
|
||||
/// there. So tell the client too — `BitrateChanged` is the same message the `SetBitrate` path
|
||||
/// answers with, and means the same thing arriving unprompted.
|
||||
fn adopt_built_bitrate(
|
||||
current: &mut u32,
|
||||
built: u32,
|
||||
live: &Arc<AtomicU32>,
|
||||
retarget: &tokio::sync::mpsc::UnboundedSender<u32>,
|
||||
) {
|
||||
if built == *current {
|
||||
return;
|
||||
}
|
||||
@@ -4085,6 +4152,7 @@ fn adopt_built_bitrate(current: &mut u32, built: u32, live: &Arc<AtomicU32>) {
|
||||
);
|
||||
*current = built;
|
||||
live.store(built, Ordering::Relaxed);
|
||||
let _ = retarget.send(built); // control task gone ⇒ the session is ending anyway
|
||||
}
|
||||
|
||||
/// Encode-stall recovery: rebuild the encoder in place (keeping capture + the session up) and
|
||||
@@ -4329,6 +4397,38 @@ fn build_pipeline(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
|
||||
const DEGRADE: u32 = 10;
|
||||
// Not escalated: the full bucket is allowed before climbs are refused.
|
||||
assert!(!encode_behind_cadence(false, 0, DEGRADE));
|
||||
assert!(!encode_behind_cadence(false, 9, DEGRADE));
|
||||
assert!(encode_behind_cadence(false, 10, DEGRADE));
|
||||
// Escalated and still missing deadlines: strict — one net behind-frame is enough.
|
||||
assert!(encode_behind_cadence(true, 1, DEGRADE));
|
||||
// Escalated, bucket fully drained: cadence is being HELD, which is what escalating was
|
||||
// for. This is the case that used to stay latched for the rest of the session and pin an
|
||||
// Automatic client at its slow-start rate.
|
||||
assert!(!encode_behind_cadence(true, 0, DEGRADE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopting_a_rebuilt_rate_tells_the_client() {
|
||||
let live = Arc::new(AtomicU32::new(20_000));
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<u32>();
|
||||
let mut current = 20_000;
|
||||
// The overwhelmingly common case: the rebuild landed on the same rate — silent.
|
||||
adopt_built_bitrate(&mut current, 20_000, &live, &tx);
|
||||
assert_eq!(rx.try_recv().ok(), None);
|
||||
// A re-resolve (the client asked 1080p, the source delivers a mirrored 4K panel): the
|
||||
// host's rate moves, so the client has to hear about it — its controller's climb base is
|
||||
// its own copy of this number, and a stale one makes the next "climb" a cut.
|
||||
adopt_built_bitrate(&mut current, 60_000, &live, &tx);
|
||||
assert_eq!(current, 60_000);
|
||||
assert_eq!(live.load(Ordering::Relaxed), 60_000);
|
||||
assert_eq!(rx.try_recv().ok(), Some(60_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_never_exceeds_the_session_rate_or_the_display() {
|
||||
// Backend honored the request exactly (the multiplier off): pace at it.
|
||||
|
||||
@@ -15,8 +15,10 @@ The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same wa
|
||||
**Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings*
|
||||
elsewhere. The Apple TV app shows one scrolling list instead, and so does any client's settings
|
||||
screen reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the
|
||||
client's **console home**, whose settings screen is one steppable list; the Decky plugin has a
|
||||
smaller section of its own. The console home is part of the client — it is not the host's
|
||||
client's **console home**, whose settings screen is one steppable list; the Decky plugin's Settings
|
||||
tab covers the same store in the same groups and the same order, as a left rail of categories the
|
||||
way SteamOS's own Settings looks. The console home is part of the
|
||||
client — it is not the host's
|
||||
[web console](/docs/web-console).
|
||||
|
||||
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the Decky plugin
|
||||
@@ -43,8 +45,9 @@ and your client scales what it gets — see
|
||||
|
||||
**Match window** — *default: off.* The stream mode follows your window instead, and each resize
|
||||
renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen
|
||||
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and console
|
||||
home screens; not by Android or Decky.
|
||||
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad, console
|
||||
home and Decky screens (on Decky it sits in the Resolution picker, and Gaming-Mode streams are
|
||||
always fullscreen, so it lands on native); not by Android.
|
||||
|
||||
**Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app
|
||||
stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display,
|
||||
@@ -69,8 +72,8 @@ link. The stops run 0.5× to 4×. The result is floored to an even size and capp
|
||||
|
||||
**Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can
|
||||
also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264.
|
||||
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or an
|
||||
Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on
|
||||
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, Decky, or
|
||||
an Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on
|
||||
that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the
|
||||
device has a hardware AV1 decoder; Android never offers PyroWave.
|
||||
|
||||
@@ -83,26 +86,27 @@ Full detail: [HDR](/docs/hdr).
|
||||
needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full
|
||||
chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is
|
||||
built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware
|
||||
decode probe to pass). Android, Decky and the console home don't offer it.
|
||||
decode probe to pass). The console home and Decky offer the toggle; Android doesn't.
|
||||
|
||||
**Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is
|
||||
ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup
|
||||
becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens
|
||||
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps; the Apple and
|
||||
Android apps have carried the same setting for a while, and it is stored under the same name, so a
|
||||
[profile](/docs/profiles-and-links) means the same thing on every device.
|
||||
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps, the console home
|
||||
and Decky; the Apple and Android apps have carried the same setting for a while, and it is stored
|
||||
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
|
||||
|
||||
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
|
||||
many frames are held back before showing. Each frame absorbs roughly one screen refresh of network
|
||||
hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra
|
||||
delay bought against 17 ms of jitter. If you never see stutter, you don't need this.
|
||||
delay bought against 17 ms of jitter. If you never see stutter, you don't need this. Wherever
|
||||
**Prioritize** is offered, and greyed out until you pick Smoothness.
|
||||
|
||||
**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame
|
||||
the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display
|
||||
can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every
|
||||
driver or compositor offers a tearing mode, and where none is available the stream stays tear-free.
|
||||
The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off"
|
||||
from "off but unavailable". Linux and Windows apps.
|
||||
from "off but unavailable". Linux and Windows apps, the console home and Decky.
|
||||
|
||||
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel
|
||||
refresh in step with the stream rather than on a fixed cadence — which removes the wait between a
|
||||
@@ -111,7 +115,8 @@ windowed one is at the compositor's mercy) and is harmless on a fixed-refresh sc
|
||||
graphics driver that offers the modern queue-free display mode; on an older driver it does nothing
|
||||
unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the
|
||||
older way of following a panel costs noticeable latency on a fixed-refresh screen. The stats overlay
|
||||
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps.
|
||||
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps,
|
||||
the console home and Decky.
|
||||
|
||||
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual
|
||||
output. Advisory: a host without that backend quietly auto-detects instead.
|
||||
@@ -123,7 +128,7 @@ stereo. The count the host will really send comes back in the handshake, and you
|
||||
decoder from *that*, never from the request. What surround means differs by host: a **Linux** host
|
||||
claims a sink advertising exactly that many channels, so applications produce real surround, while a
|
||||
**Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1
|
||||
from a stereo endpoint is an upmix, not new channels. Offered everywhere except the Decky plugin.
|
||||
from a stereo endpoint is an upmix, not new channels. Offered everywhere.
|
||||
|
||||
**Microphone** — *default: off on Linux, Windows, Android, the console home and Decky; on in the
|
||||
Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the
|
||||
@@ -137,16 +142,18 @@ from an echo-cancelled PipeWire source when your desktop provides one, on **Wind
|
||||
for the Communications stream category so the endpoint's processing engages, and on **Apple** and
|
||||
**Android** the platform's voice-processing mode. Turn it off if your microphone already runs its
|
||||
own processing, or if the canceller makes your voice sound thin. The row sits under the microphone
|
||||
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and
|
||||
console-home clients; Decky has no toggle. What it can and can't fix is in
|
||||
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android,
|
||||
console-home and Decky clients. What it can and can't fix is in
|
||||
[Why do I hear myself](/docs/echo).
|
||||
|
||||
**Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream
|
||||
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the
|
||||
**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV,
|
||||
Android, Decky and the console home have none, and the Windows app has none and ignores a stored
|
||||
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes), the
|
||||
**Mac** app (which also has a microphone *channel* picker) and **Decky** have these — iPhone, iPad,
|
||||
Apple TV, Android and the console home have none, and the Windows app has none and ignores a stored
|
||||
speaker choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather
|
||||
than silently snapping back to the default; the Mac shows it as "Unavailable device".
|
||||
than silently snapping back to the default; the Mac shows it as "Unavailable device" and Decky as
|
||||
"(not connected)". Decky reads the endpoint list from the client's session binary, so a client
|
||||
older than the two-binary split leaves these pickers on Automatic.
|
||||
|
||||
## Input
|
||||
|
||||
@@ -186,8 +193,10 @@ which forwards *every* connected controller, each as its own player, on Linux, W
|
||||
console home. Pinning one restricts the session to that controller alone — single-player. The Android
|
||||
app has no such picker.
|
||||
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps only; Windows
|
||||
spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On, Alt+Tab and the Windows key
|
||||
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps, the console home
|
||||
and Decky; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
|
||||
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
|
||||
Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key
|
||||
(Super on Linux) reach the host while the stream has input captured. Off, they act on this machine
|
||||
instead — what you want when the stream shares a screen with local work. Either way the chords come
|
||||
back the moment you release capture with **Ctrl+Alt+Shift+Q**, the window loses focus, or the stream
|
||||
@@ -206,17 +215,22 @@ the wlroots compositors all do, and X11 sessions grab the keyboard directly. Und
|
||||
Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already
|
||||
learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by
|
||||
broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this
|
||||
toggle. The console home has no toggle — it offers wake as an explicit action on an offline host
|
||||
instead — and the Decky plugin always sends a wake before a stream starts. See
|
||||
toggle, as do the console home and Decky — and note that the Decky plugin sends a wake of its own
|
||||
before a stream starts whatever this setting says, so on a Deck it governs the client's connect
|
||||
rather than the launch. The console home also offers wake as an explicit action on an offline host.
|
||||
See
|
||||
[Wake-on-LAN](/docs/wake-on-lan).
|
||||
|
||||
**Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse
|
||||
a paired host's games and launch one directly; the Windows app still labels it experimental. There is
|
||||
no toggle in the console home or in Decky. See [Game library](/docs/game-library).
|
||||
a paired host's games and launch one directly; the Windows app still labels it experimental. The
|
||||
console home and Decky have the toggle too — on Decky it governs the *client's* screens, since the
|
||||
plugin's own library browser works either way. See [Game library](/docs/game-library).
|
||||
|
||||
**Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves
|
||||
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back
|
||||
when you return to the host list. iPhone, iPad, Apple TV and Android have no equivalent.
|
||||
when you return to the host list. The console home and Decky carry the row for the desktop client
|
||||
that shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
|
||||
and Android have no equivalent.
|
||||
|
||||
## Overlay
|
||||
|
||||
@@ -224,7 +238,8 @@ when you return to the host list. iPhone, iPad, Apple TV and Android have no equ
|
||||
superset of the one before. This setting only picks the tier a session *starts* at — you can cycle
|
||||
them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you
|
||||
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The Decky
|
||||
plugin has no stats setting. The shortcuts, and every number in the overlay, are in
|
||||
plugin has the tier picker too, in its Settings section. The shortcuts, and every number in the
|
||||
overlay, are in
|
||||
[Understanding the stats overlay](/docs/stats).
|
||||
|
||||
## Settings that are facts about your device
|
||||
@@ -236,8 +251,9 @@ stay global and **cannot be put in a settings profile**:
|
||||
vendor-ordered and falls back on its own; change it only when debugging, and note that
|
||||
`PUNKTFUNK_DECODER` overrides it
|
||||
([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux,
|
||||
Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has
|
||||
more than one adapter. The Apple and Android apps have neither.
|
||||
Windows, in the console home and in Decky; the GPU picker on Windows, and on Linux and Decky only
|
||||
when the machine has more than one adapter — which a Deck doesn't, so the row isn't there. The
|
||||
Apple and Android apps have neither.
|
||||
- **Speaker** and **Microphone** device pickers — this device's audio endpoints.
|
||||
- **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a
|
||||
preference and can live in a profile; which pad you hold cannot. **Forward controllers** is a
|
||||
|
||||
@@ -295,8 +295,23 @@ installer's `PATH` change, or call the exe by full path. On Linux the host packa
|
||||
On SteamOS, re-run `scripts/steamdeck/install.sh` (or
|
||||
`scripts/steamdeck/update.sh`). On Windows, re-run the installer and keep the scripting component.
|
||||
|
||||
**The plugin doesn't show up in the console** — check the runner is actually running with
|
||||
`punktfunk-host plugins status`, then look at its log:
|
||||
**Where a plugin's log output goes** — the console's **Logs** page, under the **Plugins** filter.
|
||||
The runner ships everything your plugins print to the host, so a plugin's own lines sit next to the
|
||||
host's, on one timeline, with the same search and download. Each is tagged `plugin:<name>` — the
|
||||
plugin's own name for lines it logged itself, `plugin:runner` for the supervisor's (starting a
|
||||
plugin, restarting a crashed one, refusing an unsafe file).
|
||||
|
||||
An empty Plugins view almost always means the runner isn't running — it is a separate service, and
|
||||
opt-in on Linux. Check with `punktfunk-host plugins status`.
|
||||
|
||||
<Callout>
|
||||
Nothing is lost if the host is down: the runner keeps buffering and sends the backlog when the host
|
||||
comes back. It says so in the log if the buffer overflowed, rather than presenting a gap as
|
||||
continuity.
|
||||
</Callout>
|
||||
|
||||
**Reading the runner's log directly** — rarely needed now, but it is the ground truth if the runner
|
||||
can't reach the host at all:
|
||||
|
||||
<Tabs items={['Linux', 'Windows']}>
|
||||
<Tab value="Linux">
|
||||
@@ -318,6 +333,22 @@ plugins (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
**A plugin can't reach a service running on the same box (Linux)** — plugins that drive a local
|
||||
daemon usually talk to it over a socket or FIFO in `/tmp`. The runner's unit shipped with
|
||||
`PrivateTmp=yes` in earlier releases, which gave it a private `/tmp` and hid all of it: the plugin would
|
||||
launch the vendor's binary happily and then time out reaching the daemon behind it, while the same
|
||||
command worked perfectly in your own shell. If you are on an older host, or you have a drop-in that
|
||||
reinstates it, put the real `/tmp` back:
|
||||
|
||||
```sh
|
||||
systemctl --user edit punktfunk-scripting
|
||||
```
|
||||
```ini
|
||||
[Service]
|
||||
PrivateTmp=no
|
||||
ReadWritePaths=/tmp
|
||||
```
|
||||
|
||||
## Writing your own
|
||||
|
||||
A plugin is a TypeScript module built on **`@punktfunk/plugin-kit`** (`definePluginKit`), supervised
|
||||
|
||||
@@ -360,7 +360,10 @@ Read the host's log around the failed connect or capture.
|
||||
|
||||
1. Open the web console's **Logs** page. It always holds the host's recent output at *debug* detail,
|
||||
whatever the log level is set to — there's nothing to switch on and no restart needed.
|
||||
2. Filter it down to the level or the text you're after.
|
||||
2. Filter it down to the level or the text you're after. The **Host / Plugins** switch beside the
|
||||
level buttons picks the producer: your [plugins](/docs/plugins) log to the same page, tagged
|
||||
`plugin:<name>`, so a misbehaving plugin is one click away rather than a separate hunt through
|
||||
the journal.
|
||||
3. Use **Download logs** to save exactly what you're filtering on as a timestamped `.log` file you
|
||||
can attach to a bug report. The button beside it hands the same text to your phone or tablet's
|
||||
share sheet, or copies it to the clipboard on a desktop.
|
||||
|
||||
@@ -107,8 +107,9 @@ Nine destinations in the sidebar (a **More** tab on a phone holds the last five)
|
||||
title with its own art and launch command. See [Your game library](/docs/game-library).
|
||||
- **Performance** — arm a capture, run a session, stop it, and read the recording back as
|
||||
per-stage latency, throughput and health graphs.
|
||||
- **Logs** — the host's recent log stream: follow it live, filter by level, search it, and download
|
||||
or share it for a bug report.
|
||||
- **Logs** — the host's recent log stream *and your plugins'*: follow it live, filter by level or
|
||||
producer, search it, and download or share it for a bug report. Plugin lines are tagged
|
||||
`plugin:<name>` and the **Host / Plugins** switch isolates either side.
|
||||
- **Pairing** — arm a PIN, approve or deny devices waiting for approval, and unpair a device. A
|
||||
second PIN box for [Moonlight/GameStream](/docs/moonlight) clients appears only when this host
|
||||
runs the GameStream plane.
|
||||
|
||||
@@ -33,16 +33,24 @@ KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
# Sandbox: free hardening for well-behaved plugins. The filesystem is read-only outside the home
|
||||
# directory (ReadWritePaths keeps plugin state, download dirs, and ~/.config/punktfunk writable);
|
||||
# /tmp is private; no setuid re-escalation; sockets limited to what automation actually uses
|
||||
# (loopback mgmt API, LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME
|
||||
# (e.g. a library on another mount) gets a drop-in:
|
||||
# no setuid re-escalation; sockets limited to what automation actually uses (loopback mgmt API,
|
||||
# LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME (e.g. a library on
|
||||
# another mount) gets a drop-in:
|
||||
# systemctl --user edit punktfunk-scripting → [Service]\nReadWritePaths=/mnt/games
|
||||
# NOTE: the mount-namespace options (ProtectSystem/PrivateTmp) need unprivileged user namespaces
|
||||
# for a *user* unit; on kernels/distros that restrict those, drop them via the same drop-in.
|
||||
# NOTE: the mount-namespace options (ProtectSystem) need unprivileged user namespaces for a
|
||||
# *user* unit; on kernels/distros that restrict those, drop them via the same drop-in.
|
||||
#
|
||||
# PrivateTmp is deliberately OFF (field report 2026-08-03, the VirtualHere plugin). A plugin's
|
||||
# whole job is integrating with things already running on this box, and on Linux those talk over
|
||||
# /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient + /tmp/vhclient_response, and X11
|
||||
# is /tmp/.X11-unix. A private /tmp namespace hides all of it — the plugin launches the vendor
|
||||
# binary fine and then cannot reach the daemon behind it, which presents as an unexplained error
|
||||
# that no amount of config fixes (the operator's own shell works, because that has the real /tmp).
|
||||
# ReadWritePaths=/tmp puts the write bit back that ProtectSystem=strict takes away.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateTmp=no
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=%h
|
||||
ReadWritePaths=%h /tmp
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
|
||||
+103
-2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,287 @@
|
||||
// The runner's log door into the web console (field report 2026-08-03, the VirtualHere plugin).
|
||||
//
|
||||
// WHY THIS EXISTS: plugins are not host child processes. The runner is a separate bun process that
|
||||
// `import()`s each plugin in-process, so a plugin's output is THIS process's stdout and the host's
|
||||
// `tracing` ring — the thing `GET /api/v1/logs` and the console's Logs page serve — never sees a
|
||||
// byte of it. On Linux the fallback was `journalctl --user -u punktfunk-scripting`; on Windows the
|
||||
// runner scheduled task writes no log file AT ALL, so a failing plugin could only be diagnosed by
|
||||
// stopping the task and re-running the runner by hand. Both need shell access on the host box,
|
||||
// which is the exact thing the console exists to avoid. A user hitting a plugin misconfiguration
|
||||
// therefore had no way to see the error explaining it.
|
||||
//
|
||||
// So: tee every console line to `POST /api/v1/plugins/logs`, which lands it in the host's ring
|
||||
// alongside the host's own lines under the target `plugin:<source>`.
|
||||
//
|
||||
// Design rules this file will not break:
|
||||
// - **stdout stays authoritative.** The original console method is called FIRST and always, so
|
||||
// journald/foreground output is unchanged whatever the host is doing. Shipping is additive.
|
||||
// - **Never recurse.** Nothing on the shipping path may log through the patched console; a failed
|
||||
// POST that logged its own failure would enqueue that line, fail again, and spin.
|
||||
// - **Never throw into a caller.** `console.log` is not allowed to fail because the host is down.
|
||||
// - **Bounded.** The queue has a hard cap and drops its OLDEST lines, then says how many — an
|
||||
// unreachable host must not turn the runner into a memory leak.
|
||||
import { format } from "node:util";
|
||||
import { type ConnectOptions, resolveConfig } from "./config.js";
|
||||
|
||||
/** The level a console method implies when a line carries no level of its own. */
|
||||
const LEVEL_BY_METHOD = {
|
||||
log: "INFO",
|
||||
info: "INFO",
|
||||
debug: "DEBUG",
|
||||
warn: "WARN",
|
||||
error: "ERROR",
|
||||
} as const;
|
||||
type Method = keyof typeof LEVEL_BY_METHOD;
|
||||
const METHODS = Object.keys(LEVEL_BY_METHOD) as Method[];
|
||||
|
||||
/**
|
||||
* `<ISO> [<source>] [LEVEL:] <message>` — the line format `plugin-kit`'s `loggingLayer` and this
|
||||
* package's `runner.ts` both emit. Parsing it back recovers the plugin name and level that the
|
||||
* formatting flattened, so the console can show `plugin:virtualhere` / `WARN` instead of one
|
||||
* undifferentiated `runner` stream.
|
||||
*
|
||||
* A line that does NOT match is still shipped — attributed to `runner` at the console method's own
|
||||
* level. A plugin calling bare `console.error("boom")` is precisely the case this must not lose.
|
||||
*/
|
||||
const STAMPED =
|
||||
/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z) \[([^\]\n]{1,64})\](?:[ \t]+([A-Z]{3,9}):)?[ \t]?([\s\S]*)$/;
|
||||
|
||||
/** Lines per POST. Must stay ≤ the host's `MAX_LOG_BATCH` or a batch is rejected wholesale. */
|
||||
const BATCH = 256;
|
||||
/** Queued lines before the oldest start dropping. ~2 MB worst case at the host's 2 KB cap. */
|
||||
const QUEUE_LIMIT = 1000;
|
||||
/** A multi-line message (a stack trace) ships as one entry per line, capped here. */
|
||||
const MAX_LINES_PER_MESSAGE = 40;
|
||||
|
||||
export interface LogShipperOptions {
|
||||
/** Connection overrides. Defaults to the same zero-config resolution `connect()` uses. */
|
||||
connect?: ConnectOptions;
|
||||
/** Flush cadence in ms (default 2000). */
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
export interface LogShipper {
|
||||
/** Send whatever is queued right now. Never rejects. */
|
||||
flush: () => Promise<void>;
|
||||
/** Restore the original console methods and stop the timer. */
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
interface Line {
|
||||
ts_ms: number;
|
||||
level: string;
|
||||
source: string;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a rendered console call into shippable lines.
|
||||
*
|
||||
* Newlines become separate entries rather than one blob: the log viewer is line-oriented, and the
|
||||
* payload that matters most here — an Effect `Cause.pretty` stack from a plugin that failed to
|
||||
* start — is unreadable folded onto a single row. The cap keeps one pathological dump from
|
||||
* evicting the ring on its own.
|
||||
*/
|
||||
const toLines = (method: Method, args: unknown[]): Line[] => {
|
||||
const text = format(...args);
|
||||
const m = STAMPED.exec(text);
|
||||
const source = m?.[2] ?? "runner";
|
||||
const level = m?.[3] ?? LEVEL_BY_METHOD[method];
|
||||
const parsedTs = m?.[1] !== undefined ? Date.parse(m[1]) : Number.NaN;
|
||||
const ts_ms = Number.isNaN(parsedTs) ? Date.now() : parsedTs;
|
||||
const body = m?.[4] ?? text;
|
||||
|
||||
const parts = body.split(/\r?\n/);
|
||||
const kept = parts.slice(0, MAX_LINES_PER_MESSAGE);
|
||||
if (parts.length > kept.length) {
|
||||
kept.push(`… ${parts.length - kept.length} more line(s) suppressed`);
|
||||
}
|
||||
// A trailing newline yields one empty part; an all-empty message still ships one entry so the
|
||||
// console never silently swallows a call.
|
||||
const meaningful = kept.filter((l) => l.trim() !== "");
|
||||
return (meaningful.length > 0 ? meaningful : [""]).map((msg) => ({
|
||||
ts_ms,
|
||||
level,
|
||||
source,
|
||||
msg,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Patch the console to tee into the host's log ring, and start the flush timer.
|
||||
*
|
||||
* Install this ONLY in the managed runner (`runner-cli.ts`). A plugin's own CLI (`punktfunk-plugin-x
|
||||
* doctor`) must keep its output local — an operator running a diagnostic in their terminal is not
|
||||
* asking to write to the host's log.
|
||||
*/
|
||||
export const installLogShipper = (
|
||||
options: LogShipperOptions = {},
|
||||
): LogShipper => {
|
||||
const queue: Line[] = [];
|
||||
let droppedByOverflow = 0;
|
||||
// True from the moment a flush claims a batch until its POST settles. Guards flush RE-ENTRY
|
||||
// only — the interval can fire while a slow POST is still open, and two concurrent flushes
|
||||
// would splice disjoint batches out of one queue and deliver them out of order.
|
||||
//
|
||||
// It deliberately does NOT gate `enqueue`. It used to, as a recursion guard, and that silently
|
||||
// dropped every line logged during the few ms of a POST — which under load is a great many of
|
||||
// them, and exactly the lines a busy plugin is producing. The recursion it was guarding is
|
||||
// handled by the rule at the top of this file instead: nothing on the shipping path logs.
|
||||
let shipping = false;
|
||||
let stopped = false;
|
||||
// Set once the host's URL/token/CA resolve. Before that (the host writes `plugin-token` as it
|
||||
// boots, and the runner may well start first) lines keep queueing — those earliest lines are
|
||||
// exactly the ones that explain a plugin failing to load.
|
||||
let resolved: Awaited<ReturnType<typeof resolveConfig>> | undefined;
|
||||
let resolving = false;
|
||||
// Consecutive failures, for backoff. The host being down is normal (restart, update) and must
|
||||
// not mean a POST attempt every 2 s forever.
|
||||
let failures = 0;
|
||||
let skipTicks = 0;
|
||||
|
||||
// The ORIGINAL function objects, unbound. `stop()` must put back exactly what it found: binding
|
||||
// here and restoring the bound copy would leave a different function in place each cycle, so
|
||||
// an install/stop/install sequence accumulates a wrapper per round. Calls go through `.call`
|
||||
// below to keep `this` right without touching identity.
|
||||
const original = Object.fromEntries(METHODS.map((m) => [m, console[m]])) as Record<
|
||||
Method,
|
||||
(...args: unknown[]) => void
|
||||
>;
|
||||
|
||||
const enqueue = (method: Method, args: unknown[]) => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
for (const line of toLines(method, args)) {
|
||||
if (queue.length >= QUEUE_LIMIT) {
|
||||
queue.shift();
|
||||
droppedByOverflow += 1;
|
||||
}
|
||||
queue.push(line);
|
||||
}
|
||||
} catch {
|
||||
// Formatting a hostile object must never break the caller's console call.
|
||||
}
|
||||
};
|
||||
|
||||
for (const method of METHODS) {
|
||||
console[method] = ((...args: unknown[]) => {
|
||||
original[method].call(console, ...args);
|
||||
enqueue(method, args);
|
||||
}) as typeof console.log;
|
||||
}
|
||||
|
||||
const ensureResolved = async (): Promise<boolean> => {
|
||||
if (resolved) return true;
|
||||
if (resolving) return false;
|
||||
resolving = true;
|
||||
try {
|
||||
resolved = await resolveConfig(options.connect);
|
||||
return true;
|
||||
} catch {
|
||||
// No token yet (or no host at all). Keep buffering and try again next tick.
|
||||
return false;
|
||||
} finally {
|
||||
resolving = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Put a failed batch back at the FRONT, still honoring the cap (oldest lose). */
|
||||
const requeue = (batch: Line[]) => {
|
||||
queue.unshift(...batch);
|
||||
if (queue.length > QUEUE_LIMIT) {
|
||||
droppedByOverflow += queue.length - QUEUE_LIMIT;
|
||||
queue.splice(0, queue.length - QUEUE_LIMIT);
|
||||
}
|
||||
};
|
||||
|
||||
const flush = async (): Promise<void> => {
|
||||
if (stopped || shipping || queue.length === 0) return;
|
||||
if (!(await ensureResolved()) || !resolved) return;
|
||||
// Re-check after the await: `ensureResolved` yields, so another flush may have claimed the
|
||||
// queue in the meantime.
|
||||
if (stopped || shipping || queue.length === 0) return;
|
||||
|
||||
shipping = true;
|
||||
const batch = queue.splice(0, BATCH);
|
||||
if (droppedByOverflow > 0) {
|
||||
// Tell the operator the tail is incomplete rather than presenting a gap as continuity —
|
||||
// the same contract the host's ring keeps with its `dropped` flag.
|
||||
batch.unshift({
|
||||
ts_ms: Date.now(),
|
||||
level: "WARN",
|
||||
source: "runner",
|
||||
msg: `log shipper dropped ${droppedByOverflow} line(s): the queue filled while the host was unreachable`,
|
||||
});
|
||||
droppedByOverflow = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await resolved.fetch(`${resolved.url}/api/v1/plugins/logs`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${resolved.token}`,
|
||||
},
|
||||
body: JSON.stringify({ entries: batch }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 4xx is our bug (a shape the host rejects) and retrying cannot fix it — drop the
|
||||
// batch. 5xx/transport is the host's problem and worth keeping.
|
||||
if (res.status >= 500) requeue(batch);
|
||||
// A token that went stale (host re-keyed) resolves again from disk on the next tick.
|
||||
if (res.status === 401) resolved = undefined;
|
||||
failures += 1;
|
||||
} else {
|
||||
failures = 0;
|
||||
}
|
||||
} catch {
|
||||
requeue(batch);
|
||||
failures += 1;
|
||||
} finally {
|
||||
shipping = false;
|
||||
// 2 s, 4 s, 8 s … capped at ~30 s while the host stays away.
|
||||
skipTicks = failures === 0 ? 0 : Math.min(2 ** (failures - 1), 15);
|
||||
}
|
||||
};
|
||||
|
||||
// The most recent flush, so an explicit `flush()` can WAIT for a periodic one rather than hit
|
||||
// the re-entry guard and return having sent nothing. That matters on the shutdown path: the
|
||||
// runner flushes once more after its units' finalizers have run, and those last lines are the
|
||||
// ones that say whether the shutdown was clean. The window is widest exactly when the host is
|
||||
// slow — which is when the logs are worth most.
|
||||
let inFlight: Promise<void> = Promise.resolve();
|
||||
const runFlush = (): Promise<void> => {
|
||||
inFlight = flush();
|
||||
return inFlight;
|
||||
};
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (skipTicks > 0) {
|
||||
skipTicks -= 1;
|
||||
return;
|
||||
}
|
||||
void runFlush();
|
||||
}, options.intervalMs ?? 2_000);
|
||||
// The runner parks on its own keep-alive handle; this timer must not be what holds the process
|
||||
// open, or a runner with nothing to run would never exit.
|
||||
timer.unref?.();
|
||||
|
||||
return {
|
||||
flush: async () => {
|
||||
skipTicks = 0;
|
||||
// `flush` never rejects (every path is caught); `.catch` only keeps that a guarantee.
|
||||
await inFlight.catch(() => {});
|
||||
await runFlush();
|
||||
},
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
for (const method of METHODS) {
|
||||
console[method] = original[method] as typeof console.log;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Exported for tests. */
|
||||
export const __test = { toLines, STAMPED };
|
||||
+17
-2
@@ -22,6 +22,7 @@
|
||||
// plugin store (crates/punktfunk-host/src/store), which installs one reviewed version of a
|
||||
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
|
||||
import { Effect, Fiber } from "effect";
|
||||
import { installLogShipper } from "./log-ship.js";
|
||||
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
|
||||
import { discoverUnits, runner } from "./runner.js";
|
||||
|
||||
@@ -157,16 +158,30 @@ if (process.argv.includes("--list")) {
|
||||
// nothing at all: field report 2026-07-25 had it pinning a full core indefinitely, `strace`
|
||||
// showing a bare `clock_gettime` loop and nothing else. One idle handle is the whole fix.
|
||||
const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
|
||||
|
||||
// Tee this process's output to the host so the console's Logs page can show it. Installed HERE and
|
||||
// not in `runner.ts`, so it covers the supervised run only: a plugin's own CLI builds the same
|
||||
// layer graph, and an operator running `punktfunk-plugin-x doctor` in their terminal is not asking
|
||||
// to write into the host's log. Must be installed before the runner starts — the lines that explain
|
||||
// a plugin failing to load are the first ones out.
|
||||
const shipper = installLogShipper();
|
||||
|
||||
const fiber = Effect.runFork(runner(options));
|
||||
let stopping = false;
|
||||
const shutdown = (signal: string) => {
|
||||
if (stopping) return process.exit(1); // second signal = get out now
|
||||
stopping = true;
|
||||
console.log(`${new Date().toISOString()} [runner] ${signal} — interrupting units…`);
|
||||
void Effect.runPromise(Fiber.interrupt(fiber)).finally(() => process.exit(0));
|
||||
void Effect.runPromise(Fiber.interrupt(fiber))
|
||||
// Ship what the finalizers just said before the process goes away — a clean shutdown's
|
||||
// last lines are the ones that tell you whether it WAS clean.
|
||||
.finally(() => shipper.flush())
|
||||
.finally(() => process.exit(0));
|
||||
};
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
await Effect.runPromise(Fiber.await(fiber));
|
||||
clearInterval(keepAlive); // every unit ended on its own — let the process exit
|
||||
await shipper.flush(); // every unit ended on its own — don't leave their last lines unsent
|
||||
shipper.stop();
|
||||
clearInterval(keepAlive); // …then let the process exit
|
||||
|
||||
+39
-13
@@ -41,10 +41,24 @@ export interface RunnerOptions {
|
||||
connect?: ConnectOptions;
|
||||
/** Restart backoff base (test seam). Default 1 s, capped at 60 s, jittered. */
|
||||
restartBase?: Duration.Input;
|
||||
/** Line sink. Default: stamped stdout. */
|
||||
log?: (line: string) => void;
|
||||
/**
|
||||
* Line sink. Default: stamped stdout, with `warn`/`error` going to the matching console method
|
||||
* (hence stderr, and hence the right level in the console's log page — see `log-ship.ts`).
|
||||
*
|
||||
* `level` is optional so an existing `(line: string) => void` sink stays assignable.
|
||||
*/
|
||||
log?: (line: string, level?: RunnerLogLevel) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity of a runner line. Only three, because that is all the runner distinguishes: it is
|
||||
* reporting on units, not producing application logs.
|
||||
*/
|
||||
export type RunnerLogLevel = "info" | "warn" | "error";
|
||||
|
||||
/** The sink shape used internally, with the level always supplied by the caller's default. */
|
||||
type LogSink = (line: string, level?: RunnerLogLevel) => void;
|
||||
|
||||
export interface Unit {
|
||||
/** Display name: the file stem, or the plugin package name. */
|
||||
name: string;
|
||||
@@ -52,8 +66,12 @@ export interface Unit {
|
||||
file: string;
|
||||
}
|
||||
|
||||
const defaultLog = (line: string) =>
|
||||
console.log(`${new Date().toISOString()} ${line}`);
|
||||
const defaultLog: LogSink = (line, level = "info") => {
|
||||
const stamped = `${new Date().toISOString()} ${line}`;
|
||||
if (level === "error") console.error(stamped);
|
||||
else if (level === "warn") console.warn(stamped);
|
||||
else console.log(stamped);
|
||||
};
|
||||
|
||||
// ---- unit-file trust (the sshd rule, both halves) ---------------------------------------------
|
||||
|
||||
@@ -225,7 +243,7 @@ const windowsPowershellEnv = (): Record<string, string | undefined> => {
|
||||
};
|
||||
|
||||
/** Read a file's SDDL and apply [`windowsSddlUnsafeReason`]. Unreadable ACL ⇒ refuse. */
|
||||
const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => {
|
||||
const windowsFileIsSafe = (file: string, log: LogSink): boolean => {
|
||||
const escaped = file.replace(/'/g, "''");
|
||||
const res = spawnSync(
|
||||
windowsPowershell(),
|
||||
@@ -244,7 +262,7 @@ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => {
|
||||
);
|
||||
const sddl = res.status === 0 ? (res.stdout ?? "").trim() : "";
|
||||
if (!sddl) {
|
||||
log(`[runner] REFUSING ${file} — could not read its ACL`);
|
||||
log(`[runner] REFUSING ${file} — could not read its ACL`, "error");
|
||||
return false;
|
||||
}
|
||||
const reason = windowsSddlUnsafeReason(sddl, processSid());
|
||||
@@ -253,6 +271,7 @@ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => {
|
||||
`[runner] REFUSING ${file} — ${reason}. Reinstall the plugin with ` +
|
||||
`\`punktfunk-host plugins add\`, or re-own the file to Administrators and strip ` +
|
||||
`non-admin write ACEs (icacls).`,
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -264,13 +283,14 @@ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => {
|
||||
* could have written — group/world-writable mode on Unix; on Windows, an owner outside
|
||||
* SYSTEM/Administrators/TrustedInstaller or a write-capable ACE for a non-admin principal.
|
||||
*/
|
||||
const fileIsSafe = (file: string, log: (l: string) => void): boolean => {
|
||||
const fileIsSafe = (file: string, log: LogSink): boolean => {
|
||||
if (process.platform === "win32") return windowsFileIsSafe(file, log);
|
||||
try {
|
||||
const mode = fs.statSync(file).mode & 0o022;
|
||||
if (mode !== 0) {
|
||||
log(
|
||||
`[runner] REFUSING ${file} — group/world-writable (chmod go-w it first)`,
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -285,7 +305,7 @@ const SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".mts", ".cjs"]);
|
||||
/** Enumerate the operator's units: loose scripts plus installed plugin packages. */
|
||||
export const discoverUnits = (
|
||||
options: RunnerOptions = {},
|
||||
log: (l: string) => void = options.log ?? defaultLog,
|
||||
log: LogSink = options.log ?? defaultLog,
|
||||
): Unit[] => {
|
||||
const units: Unit[] = [];
|
||||
const scriptsDir = options.scriptsDir ?? path.join(configDir(), "scripts");
|
||||
@@ -332,7 +352,7 @@ export const discoverUnits = (
|
||||
if (!fileIsSafe(file, log)) return;
|
||||
units.push({ name, file });
|
||||
} catch (e) {
|
||||
log(`[runner] skipping ${name}: unreadable package.json (${e})`);
|
||||
log(`[runner] skipping ${name}: unreadable package.json (${e})`, "warn");
|
||||
}
|
||||
};
|
||||
try {
|
||||
@@ -379,7 +399,7 @@ const attemptUnit = (
|
||||
unit: Unit,
|
||||
attempt: number,
|
||||
options: RunnerOptions,
|
||||
log: (l: string) => void,
|
||||
log: LogSink,
|
||||
): Effect.Effect<"plugin" | "script", unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const mod = (yield* Effect.tryPromise(
|
||||
@@ -431,7 +451,8 @@ export const superviseUnit = (
|
||||
let attempt = 0;
|
||||
const once = Effect.suspend(() => {
|
||||
attempt += 1;
|
||||
if (attempt > 1) log(`[${unit.name}] restarting (attempt ${attempt})`);
|
||||
if (attempt > 1)
|
||||
log(`[${unit.name}] restarting (attempt ${attempt})`, "warn");
|
||||
return attemptUnit(unit, attempt, options, log);
|
||||
});
|
||||
return once.pipe(
|
||||
@@ -446,13 +467,18 @@ export const superviseUnit = (
|
||||
),
|
||||
Effect.tapCause((cause) =>
|
||||
Effect.sync(() =>
|
||||
log(`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`),
|
||||
log(
|
||||
`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`,
|
||||
"error",
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.retry(restart),
|
||||
Effect.catchCause((cause) =>
|
||||
// A retry schedule that gives up (it doesn't, but stay total) — log and end.
|
||||
Effect.sync(() => log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`)),
|
||||
Effect.sync(() =>
|
||||
log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`, "error"),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// The log shipper's contract: what it recovers from a formatted line, that it tees rather than
|
||||
// swallows, that a POST failure neither loses lines nor spins, and that it stays bounded.
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { __test, installLogShipper } from "../src/log-ship.js";
|
||||
|
||||
const { toLines } = __test;
|
||||
|
||||
const TOKEN = "ship-token";
|
||||
|
||||
interface Captured {
|
||||
entries: { ts_ms: number; level: string; source: string; msg: string }[];
|
||||
}
|
||||
|
||||
/** A host that records every batch, answering with whatever `status()` says. */
|
||||
const mockHost = (status: () => number = () => 204) => {
|
||||
const batches: Captured[] = [];
|
||||
const auth: (string | null)[] = [];
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (req) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname !== "/api/v1/plugins/logs") {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
auth.push(req.headers.get("authorization"));
|
||||
batches.push((await req.json()) as Captured);
|
||||
const s = status();
|
||||
return new Response(s === 204 ? null : "nope", { status: s });
|
||||
},
|
||||
});
|
||||
return {
|
||||
batches,
|
||||
auth,
|
||||
url: `http://127.0.0.1:${server.port}`,
|
||||
stop: () => server.stop(true),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `body` with a shipper installed, always restoring the console.
|
||||
*
|
||||
* The console is swapped for a recorder BEFORE the shipper installs, so `seen` is what the shipper
|
||||
* teed through to "stdout" — and the suite stays readable, since a test that logs 2000 lines would
|
||||
* otherwise print all 2000.
|
||||
*/
|
||||
const withShipper = async <T>(
|
||||
url: string,
|
||||
body: (
|
||||
s: ReturnType<typeof installLogShipper>,
|
||||
seen: string[],
|
||||
) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const seen: string[] = [];
|
||||
const real = { log: console.log, warn: console.warn, error: console.error };
|
||||
const record = (...a: unknown[]) => {
|
||||
seen.push(String(a[0]));
|
||||
};
|
||||
console.log = record;
|
||||
console.warn = record;
|
||||
console.error = record;
|
||||
const shipper = installLogShipper({
|
||||
connect: { url, token: TOKEN },
|
||||
// Long enough that only explicit flushes fire — the tests drive the timing.
|
||||
intervalMs: 60_000,
|
||||
});
|
||||
try {
|
||||
return await body(shipper, seen);
|
||||
} finally {
|
||||
shipper.stop();
|
||||
console.log = real.log;
|
||||
console.warn = real.warn;
|
||||
console.error = real.error;
|
||||
}
|
||||
};
|
||||
|
||||
describe("parsing a formatted line", () => {
|
||||
test("recovers the plugin name and timestamp plugin-kit's format flattened", () => {
|
||||
const [line] = toLines(
|
||||
"log",
|
||||
["2026-08-03T10:11:12.345Z [virtualhere] holding nothing"],
|
||||
);
|
||||
expect(line?.source).toBe("virtualhere");
|
||||
expect(line?.level).toBe("INFO");
|
||||
expect(line?.msg).toBe("holding nothing");
|
||||
expect(line?.ts_ms).toBe(Date.parse("2026-08-03T10:11:12.345Z"));
|
||||
});
|
||||
|
||||
test("an explicit level in the line beats the console method", () => {
|
||||
// plugin-kit renders Effect's level verbatim — "WARNING", not "WARN". The host coerces it.
|
||||
const [line] = toLines("log", [
|
||||
"2026-08-03T10:11:12.345Z [virtualhere] WARNING: vhclient failed (ETIMEDOUT)",
|
||||
]);
|
||||
expect(line?.level).toBe("WARNING");
|
||||
expect(line?.msg).toBe("vhclient failed (ETIMEDOUT)");
|
||||
});
|
||||
|
||||
test("the runner's own error lines keep their severity", () => {
|
||||
const [line] = toLines("error", [
|
||||
"2026-08-03T10:11:12.345Z [virtualhere] failed: VhIpcError: no such binary",
|
||||
]);
|
||||
expect(line?.source).toBe("virtualhere");
|
||||
expect(line?.level).toBe("ERROR");
|
||||
});
|
||||
|
||||
test("an unstamped call is kept, not dropped", () => {
|
||||
// A plugin reaching for bare console.error is exactly the case that must not be lost.
|
||||
const [line] = toLines("error", ["boom", { code: 7 }]);
|
||||
expect(line?.source).toBe("runner");
|
||||
expect(line?.level).toBe("ERROR");
|
||||
expect(line?.msg).toContain("boom");
|
||||
expect(line?.msg).toContain("7");
|
||||
});
|
||||
|
||||
test("a multi-line message becomes one entry per line", () => {
|
||||
const lines = toLines("log", [
|
||||
"2026-08-03T10:11:12.345Z [x] failed\n at foo\n at bar",
|
||||
]);
|
||||
expect(lines.map((l) => l.msg)).toEqual(["failed", " at foo", " at bar"]);
|
||||
// Every fragment keeps the original stamp, so the trace cannot interleave with other units.
|
||||
expect(new Set(lines.map((l) => l.ts_ms)).size).toBe(1);
|
||||
});
|
||||
|
||||
test("a pathological dump is capped and says so", () => {
|
||||
const lines = toLines("log", [
|
||||
`2026-08-03T10:11:12.345Z [x] ${"line\n".repeat(200)}`,
|
||||
]);
|
||||
expect(lines.length).toBeLessThanOrEqual(41);
|
||||
expect(lines.at(-1)?.msg).toContain("more line(s) suppressed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shipping", () => {
|
||||
let stopHost: (() => void) | undefined;
|
||||
afterEach(() => {
|
||||
stopHost?.();
|
||||
stopHost = undefined;
|
||||
});
|
||||
|
||||
test("tees: stdout still gets the line, and the host gets it too", async () => {
|
||||
const host = mockHost();
|
||||
stopHost = host.stop;
|
||||
const seen = await withShipper(host.url, async (shipper, seen) => {
|
||||
console.log("2026-08-03T10:11:12.345Z [virtualhere] hello");
|
||||
await shipper.flush();
|
||||
return seen;
|
||||
});
|
||||
|
||||
// The original console still ran — journald/foreground output is never traded away.
|
||||
expect(seen).toEqual(["2026-08-03T10:11:12.345Z [virtualhere] hello"]);
|
||||
expect(host.batches).toHaveLength(1);
|
||||
expect(host.batches[0]?.entries[0]).toMatchObject({
|
||||
source: "virtualhere",
|
||||
level: "INFO",
|
||||
msg: "hello",
|
||||
});
|
||||
expect(host.auth[0]).toBe(`Bearer ${TOKEN}`);
|
||||
});
|
||||
|
||||
test("a 5xx keeps the lines for the next flush", async () => {
|
||||
let status = 500;
|
||||
const host = mockHost(() => status);
|
||||
stopHost = host.stop;
|
||||
await withShipper(host.url, async (shipper) => {
|
||||
console.log("2026-08-03T10:11:12.345Z [x] keep me");
|
||||
await shipper.flush();
|
||||
expect(host.batches).toHaveLength(1);
|
||||
status = 204;
|
||||
await shipper.flush();
|
||||
});
|
||||
// Re-sent rather than dropped: the host being down is not the line's fault.
|
||||
expect(host.batches).toHaveLength(2);
|
||||
expect(host.batches[1]?.entries[0]?.msg).toBe("keep me");
|
||||
});
|
||||
|
||||
test("a 4xx drops the batch instead of retrying forever", async () => {
|
||||
const host = mockHost(() => 400);
|
||||
stopHost = host.stop;
|
||||
await withShipper(host.url, async (shipper) => {
|
||||
console.log("2026-08-03T10:11:12.345Z [x] malformed");
|
||||
await shipper.flush();
|
||||
await shipper.flush();
|
||||
});
|
||||
// A shape the host rejects cannot be fixed by sending it again.
|
||||
expect(host.batches).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a line logged WHILE a POST is in flight is not lost", async () => {
|
||||
// A plugin logging during the few ms of a POST is the normal case under load, not an edge
|
||||
// one. An earlier version held a `shipping` flag across the whole `await fetch` and dropped
|
||||
// everything enqueued in that window — silently, which is the worst way to lose a log line.
|
||||
let release: (() => void) | undefined;
|
||||
const held = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
let arrived: (() => void) | undefined;
|
||||
// Resolves once the server actually has the request — i.e. the shipper is genuinely mid-POST.
|
||||
// Logging merely "after calling flush()" proves nothing: flush yields at its own awaits long
|
||||
// before the fetch starts, so the line would land in the pre-send queue and the test would
|
||||
// pass against the broken code.
|
||||
const received = new Promise<void>((r) => {
|
||||
arrived = r;
|
||||
});
|
||||
const batches: Captured[] = [];
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (req) => {
|
||||
batches.push((await req.json()) as Captured);
|
||||
arrived?.();
|
||||
await held; // hold this POST open
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
stopHost = () => server.stop(true);
|
||||
|
||||
await withShipper(`http://127.0.0.1:${server.port}`, async (shipper) => {
|
||||
console.log("2026-08-03T10:11:12.345Z [x] before");
|
||||
const inFlight = shipper.flush();
|
||||
await received;
|
||||
// The POST is open right now; this is the line that used to vanish.
|
||||
console.log("2026-08-03T10:11:12.345Z [x] during");
|
||||
release?.();
|
||||
await inFlight;
|
||||
await shipper.flush();
|
||||
});
|
||||
|
||||
const all = batches.flatMap((b) => b.entries.map((e) => e.msg));
|
||||
expect(all).toContain("before");
|
||||
expect(all).toContain("during");
|
||||
});
|
||||
|
||||
test("an explicit flush waits for an in-flight one instead of no-opping", async () => {
|
||||
// This is the shutdown path. The runner flushes once more after its units' finalizers have
|
||||
// run, and those last lines are the ones that say whether the shutdown WAS clean. If a
|
||||
// periodic flush happened to be mid-POST, an explicit flush that simply returned would
|
||||
// leave them unsent — and the window is widest exactly when the host is slow, which is when
|
||||
// the logs matter most.
|
||||
let release: (() => void) | undefined;
|
||||
const held = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
let arrived: (() => void) | undefined;
|
||||
const received = new Promise<void>((r) => {
|
||||
arrived = r;
|
||||
});
|
||||
let first = true;
|
||||
const batches: Captured[] = [];
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (req) => {
|
||||
batches.push((await req.json()) as Captured);
|
||||
if (first) {
|
||||
first = false;
|
||||
arrived?.();
|
||||
await held;
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
stopHost = () => server.stop(true);
|
||||
|
||||
await withShipper(`http://127.0.0.1:${server.port}`, async (shipper) => {
|
||||
console.log("2026-08-03T10:11:12.345Z [x] first");
|
||||
const slow = shipper.flush();
|
||||
await received;
|
||||
console.log("2026-08-03T10:11:12.345Z [x] shutdown line");
|
||||
release?.();
|
||||
// The shutdown flush: must not return until the tail is actually sent.
|
||||
await shipper.flush();
|
||||
await slow;
|
||||
});
|
||||
|
||||
const all = batches.flatMap((b) => b.entries.map((e) => e.msg));
|
||||
expect(all).toContain("shutdown line");
|
||||
});
|
||||
|
||||
test("overlapping flushes do not double-send", async () => {
|
||||
// The timer can fire while a slow POST is still open. Two concurrent flushes would splice
|
||||
// disjoint batches out of one queue and deliver them out of order.
|
||||
const host = mockHost();
|
||||
stopHost = host.stop;
|
||||
await withShipper(host.url, async (shipper) => {
|
||||
console.log("2026-08-03T10:11:12.345Z [x] one");
|
||||
await Promise.all([shipper.flush(), shipper.flush()]);
|
||||
});
|
||||
expect(host.batches).toHaveLength(1);
|
||||
expect(host.batches[0]?.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("an unreachable host neither throws into the caller nor grows without bound", async () => {
|
||||
// Nothing listening on this port.
|
||||
await withShipper("http://127.0.0.1:1", async (shipper) => {
|
||||
for (let i = 0; i < 2_000; i++) {
|
||||
console.log(`2026-08-03T10:11:12.345Z [x] line ${i}`);
|
||||
}
|
||||
// The whole point: console.log above must not have thrown, and flush must not reject.
|
||||
await shipper.flush();
|
||||
});
|
||||
});
|
||||
|
||||
test("overflow is announced, not silently swallowed", async () => {
|
||||
const host = mockHost();
|
||||
stopHost = host.stop;
|
||||
await withShipper(host.url, async (shipper) => {
|
||||
// Overrun the 1000-line cap while the shipper has had no chance to drain.
|
||||
for (let i = 0; i < 1_200; i++) {
|
||||
console.log(`2026-08-03T10:11:12.345Z [x] line ${i}`);
|
||||
}
|
||||
await shipper.flush();
|
||||
});
|
||||
const first = host.batches[0]?.entries[0];
|
||||
expect(first?.level).toBe("WARN");
|
||||
expect(first?.msg).toContain("dropped");
|
||||
// The tail is what survived — the oldest lines are the ones that went.
|
||||
expect(host.batches[0]?.entries[1]?.msg).toBe("line 200");
|
||||
});
|
||||
|
||||
test("stop() puts the real console back", async () => {
|
||||
const host = mockHost();
|
||||
stopHost = host.stop;
|
||||
const before = console.log;
|
||||
const shipper = installLogShipper({
|
||||
connect: { url: host.url, token: TOKEN },
|
||||
intervalMs: 60_000,
|
||||
});
|
||||
expect(console.log).not.toBe(before);
|
||||
shipper.stop();
|
||||
expect(console.log).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -319,7 +319,11 @@
|
||||
"nav_stats": "Leistung",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "Der aktuelle Log-Stream des Hosts — live verfolgen, nach Level filtern, durchsuchen.",
|
||||
"logs_subtitle": "Der aktuelle Log-Stream des Hosts und deiner Plugins — live verfolgen, nach Level filtern, durchsuchen.",
|
||||
"logs_source_all": "Alle",
|
||||
"logs_source_host": "Host",
|
||||
"logs_source_plugins": "Plugins",
|
||||
"logs_empty_plugins": "Noch keine Plugin-Ausgabe. Plugins loggen hier, sobald der Plugin-Runner läuft — prüfe `punktfunk-host plugins status`.",
|
||||
"logs_follow": "Folgen",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Leeren",
|
||||
|
||||
@@ -319,7 +319,11 @@
|
||||
"nav_stats": "Performance",
|
||||
"nav_logs": "Logs",
|
||||
"logs_title": "Logs",
|
||||
"logs_subtitle": "The host's recent log stream — follow live, filter by level, search.",
|
||||
"logs_subtitle": "The host's recent log stream, and your plugins' — follow live, filter by level, search.",
|
||||
"logs_source_all": "All",
|
||||
"logs_source_host": "Host",
|
||||
"logs_source_plugins": "Plugins",
|
||||
"logs_empty_plugins": "No plugin output yet. Plugins log here once the plugin runner is running — check `punktfunk-host plugins status`.",
|
||||
"logs_follow": "Follow",
|
||||
"logs_pause": "Pause",
|
||||
"logs_clear": "Clear",
|
||||
|
||||
@@ -38,6 +38,29 @@ const LEVEL_CLASS: Record<string, string> = {
|
||||
const KEEP = 5_000; // accumulated entries (client memory bound)
|
||||
const SHOW = 1_000; // rendered rows (DOM bound)
|
||||
|
||||
/**
|
||||
* Producer filter. The ring carries the host's own `tracing` events AND whatever the plugin runner
|
||||
* ships up (`POST /api/v1/plugins/logs`), the latter targeted `plugin:<name>`. Without this the two
|
||||
* are interleaved with nothing but the target column to tell them apart, and "show me what my
|
||||
* plugin said" — the question that sends people to `journalctl` — means knowing to type `plugin:`
|
||||
* into the search box.
|
||||
*/
|
||||
const SOURCES = ["all", "host", "plugins"] as const;
|
||||
type Source = (typeof SOURCES)[number];
|
||||
|
||||
/** The target prefix the host stamps on every runner-shipped line. */
|
||||
const PLUGIN_TARGET_PREFIX = "plugin:";
|
||||
|
||||
const matchesSource = (target: string, source: Source): boolean =>
|
||||
source === "all" ||
|
||||
(source === "plugins") === target.startsWith(PLUGIN_TARGET_PREFIX);
|
||||
|
||||
const SOURCE_LABEL: Record<Source, () => string> = {
|
||||
all: () => m.logs_source_all(),
|
||||
host: () => m.logs_source_host(),
|
||||
plugins: () => m.logs_source_plugins(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Container: cursor-paged log polling. A non-empty page advances the cursor — a new query key,
|
||||
* so the next page fetches immediately and a backlog drains fast; an empty page leaves the key
|
||||
@@ -177,6 +200,7 @@ export const LogsCard: FC<{
|
||||
onRetry,
|
||||
}) => {
|
||||
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
|
||||
const [source, setSource] = useState<Source>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -186,11 +210,12 @@ export const LogsCard: FC<{
|
||||
return entries.filter(
|
||||
(e) =>
|
||||
(RANK[e.level] ?? 0) >= min &&
|
||||
matchesSource(e.target, source) &&
|
||||
(q === "" ||
|
||||
e.msg.toLowerCase().includes(q) ||
|
||||
e.target.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [entries, minLevel, search]);
|
||||
}, [entries, minLevel, source, search]);
|
||||
const visible = useMemo(() => matched.slice(-SHOW), [matched]);
|
||||
const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy();
|
||||
|
||||
@@ -233,6 +258,18 @@ export const LogsCard: FC<{
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 border-l pl-2">
|
||||
{SOURCES.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={source === s ? "secondary" : "ghost"}
|
||||
onClick={() => setSource(s)}
|
||||
>
|
||||
{SOURCE_LABEL[s]()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -317,7 +354,15 @@ export const LogsCard: FC<{
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{isLoading ? m.common_loading() : m.logs_empty()}
|
||||
{isLoading
|
||||
? m.common_loading()
|
||||
: // "No plugin output" has a specific, actionable cause that the generic
|
||||
// "adjust the filter" line actively misdirects from: the runner is a
|
||||
// separate service and is opt-in on Linux, so the usual reason for an
|
||||
// empty Plugins view is that it simply isn't running.
|
||||
source === "plugins"
|
||||
? m.logs_empty_plugins()
|
||||
: m.logs_empty()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,22 @@ const fixtureEntries: LogEntry[] = [
|
||||
"punktfunk_host::encode",
|
||||
"NVENC opened 1920x1080 nv12 gop=inf rfi=on",
|
||||
),
|
||||
// Lines the plugin runner shipped up (`POST /api/v1/plugins/logs`), targeted `plugin:<name>`.
|
||||
// They share the ring and the cursor with the host's own, which is what the Host/Plugins
|
||||
// filter exists to separate — so the fixture has to carry both to be worth screenshotting.
|
||||
entry(9, "INFO", "plugin:runner", "starting virtualhere"),
|
||||
entry(
|
||||
10,
|
||||
"INFO",
|
||||
"plugin:virtualhere",
|
||||
"bound couch-deck.11 (Thrustmaster T300RS) for stream",
|
||||
),
|
||||
entry(
|
||||
11,
|
||||
"ERROR",
|
||||
"plugin:virtualhere",
|
||||
"vhclientx86_64 failed (ETIMEDOUT) — the VirtualHere client is not answering on /tmp/vhclient",
|
||||
),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
|
||||
Reference in New Issue
Block a user