2 Commits

Author SHA1 Message Date
enricobuehler 8ef320662b docs: zero-copy EGL/CUDA capture-crash hardening handoff
Describes a reproduced host SIGSEGV in cuGraphicsMapResources (inside
libnvidia-eglcore), reached via zerocopy::cuda / zerocopy::egl on the tiled
EGL/GL->CUDA capture path, when the KWin dmabuf is invalidated mid-map (observed
on .181 during a Game->Desktop switch under zero-copy, with the compositor itself
crashing). Pre-existing capture-layer issue, not the gamemode work. Issue
description + root cause + solution-space considerations only -- the next agent
plans the implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 06:26:22 +00:00
enricobuehler 04309d0ad9 feat(host): game-mode integration + dedicated game sessions
Implements design/gamemode-and-dedicated-sessions.md (Parts A1-A5 + B0-B2):
reconciles the merged display-management registry with session-mobile
Bazzite/SteamOS hosts and adds a per-launch dedicated gamescope mode.

- A1 DisplayOwnership {Owned,External,SessionManaged} + poolable_now(): the
  registry pools only what it owns, so gamescope managed/attach outputs are no
  longer double-owned by the registry AND the gamescope restore worker (fixes
  the game-mode-reconnect stale-node wedge).
- A2 validated reuse: (backend,mode,launch,epoch) reuse key + kept_display_alive
  liveness probe + reused_gen/mark_failed on a reused-display first-frame failure.
- A3 policy-driven managed restore (keep_alive replaces the hardcoded 5s debounce;
  forever = held = gaming-rig truthful) + crash-restore persist + SIGKILL teardown
  (kill_unit, applied to our transient unit AND the autologin stop -- validated
  live on .181 to avoid the F44 GPU-context leak).
- A4 session epoch: observe_session_instance bumps the epoch + invalidate_backend
  on a desktop-compositor instance change; gamescope spawns are exempt.
- A5 per-spawn log + PID-scoped gamescope node discovery.
- B0 game_session {auto,dedicated} policy (top-level, preset-orthogonal) +
  pick_gamescope_mode dedicated_launch + steam -silent command shaping.
- B1 free the autologin Steam before a dedicated Steam spawn (single-instance).
- B2 game-exit -> APP_EXITED_CLOSE_CODE (0x52) clean session end.

Adversarially reviewed (11 findings fixed). Validated on glass (.181 Bazzite F44,
RTX 4090): dedicated spawn streams a real game smoothly; keep-alive reuse; the
SIGKILL fix avoids the F44 vkCreateDevice leak. Workspace green
(build / test --workspace / clippy -D warnings / fmt), OpenAPI + C header
regenerated, web console tsc + vite build green. clients/probe: bump the
no-video timeout 8s->45s for gamescope cold starts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 06:26:22 +00:00
96 changed files with 590 additions and 8653 deletions
Generated
+9 -10
View File
@@ -2129,7 +2129,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.8.2"
version = "0.8.0"
[[package]]
name = "lazy_static"
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.8.2"
version = "0.8.0"
dependencies = [
"punktfunk-core",
]
@@ -2908,7 +2908,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.8.2"
version = "0.8.0"
dependencies = [
"android_logger",
"jni",
@@ -2918,12 +2918,11 @@ dependencies = [
"ndk",
"opus",
"punktfunk-core",
"tracing",
]
[[package]]
name = "punktfunk-client-linux"
version = "0.8.2"
version = "0.8.0"
dependencies = [
"anyhow",
"async-channel",
@@ -2946,7 +2945,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.8.2"
version = "0.8.0"
dependencies = [
"anyhow",
"async-channel",
@@ -2969,7 +2968,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.8.2"
version = "0.8.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3000,7 +2999,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.8.2"
version = "0.8.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3072,7 +3071,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.8.2"
version = "0.8.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3086,7 +3085,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.8.2"
version = "0.8.0"
dependencies = [
"anyhow",
"ksni",
+1 -1
View File
@@ -17,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package]
version = "0.8.2"
version = "0.8.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -286
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.8.2"
"version": "0.8.0"
},
"paths": {
"/api/v1/clients": {
@@ -190,237 +190,6 @@
}
}
},
"/api/v1/display/presets": {
"get": {
"tags": [
"display"
],
"summary": "List the saved custom presets",
"description": "The operator's named field-bundles (`display-presets.json`). These also ride the\n`GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.",
"operationId": "listCustomPresets",
"responses": {
"200": {
"description": "The saved custom presets",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomPreset"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"post": {
"tags": [
"display"
],
"summary": "Save a custom preset",
"description": "Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can\napply later. The host assigns a stable id, returned in the body. Applying a preset is a\n`PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.",
"operationId": "createCustomPreset",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomPresetInput"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Preset created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomPreset"
}
}
}
},
"400": {
"description": "Empty name",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the catalog",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/presets/{id}": {
"put": {
"tags": [
"display"
],
"summary": "Update a custom preset",
"operationId": "updateCustomPreset",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The custom preset id",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomPresetInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Preset updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomPreset"
}
}
}
},
"400": {
"description": "Empty name",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No custom preset with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the catalog",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"delete": {
"tags": [
"display"
],
"summary": "Delete a custom preset",
"description": "Removes it from the catalog. The active policy is untouched — if this preset was the one applied,\nthe running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).",
"operationId": "deleteCustomPreset",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The custom preset id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Preset deleted"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No custom preset with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the catalog",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/release": {
"post": {
"tags": [
@@ -2451,52 +2220,6 @@
}
}
},
"CustomPreset": {
"type": "object",
"description": "A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a\nbuilt-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names\nand applies from the console.\n\nUnlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in\n`<config>/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these\nfields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and\nthe built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:\nediting or deleting a preset never mutates the running policy (re-apply to adopt a change).",
"required": [
"id",
"name",
"fields"
],
"properties": {
"fields": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The six display-behavior axes this preset applies (the same shape a built-in preset expands to)."
},
"game_session": {
"$ref": "#/components/schemas/GameSession",
"description": "The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).\nA custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying\none does set this axis."
},
"id": {
"type": "string",
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
},
"name": {
"type": "string",
"description": "User-facing name shown on the preset card; editable."
}
}
},
"CustomPresetInput": {
"type": "object",
"description": "Request body to create or replace a custom preset (no `id` — the host owns it).",
"required": [
"name",
"fields"
],
"properties": {
"fields": {
"$ref": "#/components/schemas/EffectivePolicy"
},
"game_session": {
"$ref": "#/components/schemas/GameSession"
},
"name": {
"type": "string"
}
}
},
"DisplayLayoutRequest": {
"type": "object",
"description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).",
@@ -2561,7 +2284,6 @@
"configured",
"effective",
"presets",
"custom_presets",
"enforced"
],
"properties": {
@@ -2569,13 +2291,6 @@
"type": "boolean",
"description": "True once a `display-settings.json` exists (the console has configured this host)."
},
"custom_presets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomPreset"
},
"description": "The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered\nalongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a\n`Custom` policy carrying the preset's fields."
},
"effective": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The effective (preset-expanded) policy currently in force."
@@ -13,12 +13,6 @@
reception needs it (also an OEM Wi-Fi power-save hedge). -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- WifiLock.acquire() ENFORCES this (a normal permission, granted at install). Without it the
stream's Wi-Fi locks throw SecurityException and power save stays on: downlink delivery
clumps at beacon intervals — hundreds of ms of latency mush + periodic whole-frame loss.
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
runCatching (now logged). -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
Harmless to declare on earlier releases. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
@@ -208,8 +208,6 @@ fun GamepadShell(
GamepadScreen.Library -> libraryHost?.let { host ->
LibraryScreen(
host = host,
settings = settings,
onLaunched = onConnected,
onBack = { screen = GamepadScreen.Home; libraryHost = null },
navActive = s == screen,
)
@@ -63,6 +63,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */
private const val CONNECT_TIMEOUT_MS = 10_000
/**
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than
@@ -178,10 +181,25 @@ fun ConnectScreen(
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
// Issue the native connect (shared by the normal connect and the request-access path). A plain
// desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs)
// The one place the full nativeConnect is issued (shared by the normal connect and the
// request-access path), including the HDR/gamepad derivation both need.
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long {
// Advertise HDR only when the user enabled it AND this device's display can present it
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
// explicit choice is passed through unchanged.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
return withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(
targetHost, targetPort, w, h, hz,
id.certPem, id.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
)
}
}
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it
@@ -212,12 +230,11 @@ fun ConnectScreen(
}
}
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
// already seen live) dial straight through.
// Wake-aware connect. If the target is a saved host with a learned MAC that ISN'T currently
// advertising (asleep/off), wake it and WAIT for it to reappear on mDNS (WakeController shows the
// "Waking…" overlay) before dialing — discovery stays running meanwhile so we can see it come
// back. A fire-and-forget packet + the connect timeout wasn't enough for a cold boot. Otherwise
// dial straight through.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
if (identity == null) {
status = "Identity not ready yet — try again in a moment"
@@ -231,7 +248,7 @@ fun ConnectScreen(
fun liveAdvert(): DiscoveredHost? =
if (kh != null) discovered.firstOrNull { kh.matches(it) }
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
if (macs.isNotEmpty() && liveAdvert() == null) {
waker.start(
hostName = name,
connectsAfter = true,
@@ -2,8 +2,7 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -362,15 +361,15 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -390,20 +389,15 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val visuals = animateConsoleFocus(active = focused)
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
tween(160),
label = "addLabel",
)
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
@@ -412,7 +406,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = labelColor,
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
)
}
}
@@ -454,19 +448,11 @@ private fun KeyboardGrid(
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
.clip(RoundedCornerShape(9.dp))
.background(bg)
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
@@ -474,7 +460,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = fg,
color = if (focused) Color.Black else Color.White,
textAlign = TextAlign.Center,
)
}
@@ -1,14 +1,10 @@
package io.unom.punktfunk
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
@@ -19,7 +15,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -36,28 +31,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import io.unom.punktfunk.kit.Gamepad
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sin
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
@@ -202,12 +189,9 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
}
/**
* One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android
* `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is
* actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a
* DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set,
* makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still
* drive the console UI (and reach Settings to switch it off).
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
* controller can still drive the console UI (and reach Settings to switch it off).
*/
class GamepadHint(
val glyph: Char,
@@ -217,16 +201,11 @@ class GamepadHint(
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
// for a TV remote, which has no A/B/X/Y.
val select: Boolean = false,
// Render as the pad's physical Select/View/Create/ button (per PadStyle) — the button that
// delivers KEYCODE_BUTTON_SELECT.
// Render as the gamepad Select/View button (a small capsule).
val viewButton: Boolean = false,
)
/**
* Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are
* the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo
* monochrome per the driving pad's [Gamepad.PadStyle] at draw time.
*/
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
object PadGlyph {
val A = Color(0xFF6BBE45)
val B = Color(0xFFD14B4B)
@@ -237,87 +216,6 @@ object PadGlyph {
)
}
/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */
internal val PadButtonFace = Color(0xFF2A2740)
/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */
class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color)
/**
* The focus visuals every console form element shares (settings rows, add-host fields, action
* rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and
* resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border
* of a field actively receiving keyboard input.
*/
@Composable
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
val scale by animateFloatAsState(
targetValue = if (active) 1f else 0.98f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "consoleScale",
)
val background by animateColorAsState(
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
tween(160),
label = "consoleBg",
)
val border by animateColorAsState(
when {
editing -> Color(0xB38678F5)
active -> Color.White.copy(alpha = 0.28f)
else -> Color.White.copy(alpha = 0.06f)
},
tween(160),
label = "consoleBorder",
)
return ConsoleFocusVisuals(scale, background, border)
}
/**
* The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet
* track that tints as it engages while the knob slides across on a spring — the state change reads
* from across the room, and the motion confirms the press.
*/
@Composable
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
val travel by animateFloatAsState(
targetValue = if (on) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
label = "switchKnob",
)
val track by animateColorAsState(
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
tween(200),
label = "switchTrack",
)
val outline by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
tween(160),
label = "switchOutline",
)
val trackW = 44.dp
val trackH = 24.dp
val pad = 3.dp
val knob = trackH - pad * 2
Box(
modifier
.size(trackW, trackH)
.clip(RoundedCornerShape(50))
.background(track)
.border(1.dp, outline, RoundedCornerShape(50)),
contentAlignment = Alignment.CenterStart,
) {
Box(
Modifier
.padding(horizontal = pad)
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
.size(knob)
.clip(CircleShape)
.background(Color.White),
)
}
}
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
@@ -355,96 +253,18 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
GamepadButtonGlyph('↩', PadGlyph.B, size)
}
/**
* A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it.
* Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square,
* Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic
* DualShock colours.
*/
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
@Composable
internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) {
val color = when (glyph) {
'A' -> Color(0xFF7C9CE8) // cross — light blue
'B' -> Color(0xFFE0736F) // circle — red
'X' -> Color(0xFFD48FC7) // square — pink
else -> Color(0xFF5FBFA5) // triangle — green
}
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
) {
Canvas(Modifier.size(size * 0.46f)) {
val w = this.size.minDimension
val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round)
when (glyph) {
'A' -> { // ✕ — the two diagonals
drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round)
drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round)
}
'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke)
'X' -> drawRect(
color,
topLeft = Offset(stroke.width / 2f, stroke.width / 2f),
size = Size(w - stroke.width, w - stroke.width),
style = stroke,
)
else -> { // △
val p = Path().apply {
moveTo(w / 2f, stroke.width / 2f)
lineTo(w - stroke.width / 2f, w - stroke.width / 2f)
lineTo(stroke.width / 2f, w - stroke.width / 2f)
close()
}
drawPath(p, color, style = stroke)
}
}
}
}
}
/**
* The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens
* Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two
* overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo (minus). The generic
* fallback wears the capsule too (the near-universal select shape).
*/
@Composable
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
) {
when (style) {
Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) {
// The View icon: two overlapping outlined windows; the front one is filled with the
// button face so it visibly occludes the back one.
val corner = RoundedCornerShape(2.dp)
Box(
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
Box(
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
.clip(corner).background(PadButtonFace)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
}
Gamepad.PadStyle.NINTENDO -> Text(
"",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.62f).sp,
textAlign = TextAlign.Center,
)
else -> Box(
Modifier
.size(width = size * 0.58f, height = size * 0.30f)
.size(width = size * 0.74f, height = size * 0.46f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
)
}
}
}
/**
* The pinned controls legend every gamepad screen shows along the bottom — worn as a self-contained
@@ -454,12 +274,8 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
// PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI.
// Defaults to the generic gamepad look off an Activity (preview/tests).
val activity = LocalContext.current as? MainActivity
val padIsGamepad = activity?.lastPadIsGamepad ?: true
val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
val shape = RoundedCornerShape(50)
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
@@ -484,13 +300,9 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
}
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
when {
h.viewButton -> SelectButtonGlyph(padStyle)
h.viewButton -> ViewButtonGlyph()
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" ->
PsFaceGlyph(h.glyph)
padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" ->
GamepadButtonGlyph(h.glyph, PadButtonFace)
else -> GamepadButtonGlyph(h.glyph, h.color)
}
Spacer(Modifier.width(6.dp))
@@ -2,12 +2,7 @@ package io.unom.punktfunk
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -24,8 +19,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -33,7 +26,6 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -98,11 +90,8 @@ fun GamepadDialog(
},
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
)
// Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
// landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
// the current action even when the stack scrolls.
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
@@ -120,11 +109,13 @@ fun GamepadDialog(
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
// The body scrolls; the title above and the buttons below stay pinned + always visible.
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
body()
}
Spacer(Modifier.size(4.dp))
actions.forEachIndexed { i, a ->
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
@@ -132,54 +123,29 @@ fun GamepadDialog(
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "btnScale",
)
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
// that's scrolled out of a short window, pull it into view (no-op when already visible).
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
val shape = RoundedCornerShape(14.dp)
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
val bg = when {
focused -> Color(0xFF6656F2)
primary -> Color(0x336656F2)
else -> Color(0x14FFFFFF)
},
tween(160),
label = "btnBg",
)
val fg by animateColorAsState(
when {
}
val fg = when {
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
},
tween(160),
label = "btnFg",
)
val borderColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
tween(160),
label = "btnBorder",
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(bg)
.border(1.dp, borderColor, shape)
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
@@ -2,19 +2,7 @@ package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -69,7 +57,6 @@ private class GpRow(
val detail: String,
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
)
@Composable
@@ -85,9 +72,6 @@ fun GamepadSettingsScreen(
val rows = buildSettingsRows(s, ::update)
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
// value text slides in its AnimatedContent, so the motion matches the button press.
var adjustDir by remember { mutableIntStateOf(1) }
val listState = rememberLazyListState()
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -99,11 +83,11 @@ fun GamepadSettingsScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
}
},
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
onActivate = { rows.getOrNull(focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
@@ -137,8 +121,8 @@ fun GamepadSettingsScreen(
ConsoleHeader("Settings", horizontalInset = false)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
SettingRowView(row, focused = index == focus, onClick = {
if (focus == index) row.activate() else focus = index
})
}
}
@@ -166,17 +150,9 @@ fun GamepadSettingsScreen(
}
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val visuals = animateConsoleFocus(active = focused)
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them.
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
val valueColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 1f else 0.6f),
tween(160),
label = "valueColor",
)
Column {
if (row.header != null) {
Text(
@@ -190,10 +166,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
Column(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -210,41 +186,19 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
maxLines = 1,
)
Spacer(Modifier.weight(1f))
if (row.toggled != null) {
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
// The value slides in the direction it was stepped and its width animates, so
// cycling a choice reads as motion through a list rather than a text swap.
AnimatedContent(
targetState = row.value,
transitionSpec = {
val dir = adjustDir
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
SizeTransform(clip = false)
},
label = "value",
) { value ->
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f))
Text(
value,
row.value,
style = MaterialTheme.typography.bodyMedium,
color = valueColor,
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f))
}
// The focused row carries its own one-line description — no dedicated (space-eating)
// detail strip. It unfolds right where you're looking, and the row grows to fit.
AnimatedVisibility(
visible = focused && row.detail.isNotBlank(),
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
) {
// detail strip. It appears right where you're looking, and the row grows to fit.
if (focused && row.detail.isNotBlank()) {
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
@@ -291,7 +245,6 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
detail = detail,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
)
return listOf(
@@ -325,11 +278,6 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
"HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) },
toggle(
"lowLatency", null, "Low-latency mode",
"Experimental — aggressive decoder and system tuning. Turn off if the stream stutters or glitches.",
s.lowLatencyMode,
) { update(s.copy(lowLatencyMode = it)) },
choice(
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
@@ -356,11 +304,6 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"autoWake", null, "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"gamepadUI", null, "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
@@ -1,48 +0,0 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Handshake budget for a normal / library-launch connect (not the long request-access park). */
const val CONNECT_TIMEOUT_MS = 10_000
/**
* The one place [NativeBridge.nativeConnect] is assembled — shared by [ConnectScreen] and the library
* launcher ([LibraryScreen]). Derives the mode / HDR / gamepad settings the host needs from
* [settings]. [pinHex] is the pinned fingerprint (empty ⇒ TOFU). [launch] is a store-qualified library
* id (`steam:<appid>` / `custom:<id>`) to boot straight into a game, or `null` for the desktop.
* Returns the session handle, or `0` on failure. Call off the main thread.
*/
suspend fun connectToHost(
context: Context,
settings: Settings,
identity: ClientIdentity,
host: String,
port: Int,
pinHex: String,
launch: String?,
timeoutMs: Int = CONNECT_TIMEOUT_MS,
): Long {
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
val (w, h, hz) = settings.effectiveMode(context)
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
return withContext(Dispatchers.IO) {
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
// sockets) — must be applied before connect, since sockets are tagged at creation.
NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode)
NativeBridge.nativeConnect(
host, port, w, h, hz,
identity.certPem, identity.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
launch,
)
}
}
@@ -1,10 +1,8 @@
package io.unom.punktfunk
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -59,7 +57,6 @@ import io.unom.punktfunk.kit.library.GameEntry
import io.unom.punktfunk.kit.library.LibraryClient
import io.unom.punktfunk.kit.library.LibraryResult
import io.unom.punktfunk.kit.library.mtlsHttpClient
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.obtainIdentity
@@ -76,27 +73,17 @@ import kotlinx.coroutines.withContext
private sealed class LibState {
object Loading : LibState()
// Carries the client identity so a launch can dial the host over the same pinned mTLS trust.
data class Ready(val games: List<GameEntry>, val loader: ImageLoader, val identity: ClientIdentity) : LibState()
data class Ready(val games: List<GameEntry>, val loader: ImageLoader) : LibState()
data class Message(val text: String) : LibState() // unauthorized / empty / error
}
@Composable
fun LibraryScreen(
host: KnownHost,
settings: Settings,
onLaunched: (Long) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true,
) {
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) {
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val hazeState = remember { HazeState() }
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
var launching by remember { mutableStateOf(false) }
LaunchedEffect(host.address, host.port, host.fpHex) {
state = LibState.Loading
@@ -114,7 +101,7 @@ fun LibraryScreen(
LibState.Message("No games found on this host.")
} else {
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build(), id)
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build())
}
is LibraryResult.Unauthorized -> LibState.Message(res.message)
is LibraryResult.Error -> LibState.Message(res.message)
@@ -131,45 +118,11 @@ fun LibraryScreen(
when (val s = state) {
is LibState.Loading -> LoadingState()
is LibState.Message -> MessageState(s.text)
is LibState.Ready -> Coverflow(s.games, s.loader, navActive && !launching) { game ->
if (!launching) {
launching = true
scope.launch {
// Dial the host over the same pinned mTLS trust, booting straight
// into this title (the host resolves `launch` = its library id).
val handle = connectToHost(
context, settings, s.identity,
host.address, host.port, host.fpHex, launch = game.id,
)
launching = false
if (handle != 0L) onLaunched(handle)
else Toast.makeText(
context,
"Launch failed — check the host and try again.",
Toast.LENGTH_LONG,
).show()
is LibState.Ready -> Coverflow(s.games, s.loader, navActive)
}
}
}
}
}
}
}
// Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs.
if (launching) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
CircularProgressIndicator(color = Color.White)
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
}
}
}
// Floating legend at the shared spot — same landscape-aware inset as every other console
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
Box(
@@ -177,13 +130,7 @@ fun LibraryScreen(
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
buildList {
if (state is LibState.Ready) add(PadGlyph.hint('A', "Launch"))
add(PadGlyph.hint('B', "Close", onClick = onBack))
},
hazeState = hazeState,
)
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState)
}
}
}
@@ -208,12 +155,7 @@ private fun MessageState(text: String) {
}
@Composable
private fun Coverflow(
games: List<GameEntry>,
loader: ImageLoader,
navActive: Boolean,
onLaunch: (GameEntry) -> Unit,
) {
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) {
BoxWithConstraints(Modifier.fillMaxSize()) {
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
@@ -225,15 +167,16 @@ private fun Coverflow(
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
val current = games.getOrNull(navTarget)
// Controller nav: the pad drives the coverflow. Left/right steps a coalesced target the pager
// chases; A launches the centred title; B closes via the screen's BackHandler.
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes
// via the screen's BackHandler.
GamepadNavEffect(
active = navActive && games.isNotEmpty(),
onMove = { dir ->
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
},
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
onActivate = { /* launch a title — browse-only for now */ },
)
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
@@ -255,11 +198,6 @@ private fun Coverflow(
.zIndex(-d) // centred cover on top, neighbours stacked behind
.width(coverWidth)
.height(coverHeight)
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
.clickable {
if (page == pagerState.currentPage) onLaunch(games[page])
else scope.launch { pagerState.animateScrollToPage(page) }
}
.graphicsLayer {
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
@@ -51,21 +51,8 @@ class MainActivity : ComponentActivity() {
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
* face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state).
* Defaults to the remote glyphs on a TV (its D-pad remote is the typical first input, and often the
* only one) and to gamepad glyphs everywhere else (the console UI on a phone/tablet only activates
* via a real controller, so a TV-remote glyph would be a wrong first impression there) — set from
* [onCreate] once a [Context] is available, then kept live by real input.
*/
var lastPadIsGamepad by mutableStateOf(true)
private set
/**
* The glyph family of the controller driving the console UI (Xbox letters / PlayStation shapes /
* Nintendo monochrome) — seeded from the first connected pad, then kept live by real input the
* same way [lastPadIsGamepad] is. Compose observes it (a snapshot state); the hint bar picks its
* button glyphs from it so a DualSense user isn't shown Xbox lettering.
*/
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
var lastPadIsGamepad by mutableStateOf(false)
private set
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
@@ -73,8 +60,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lastPadIsGamepad = !isTvDevice(this)
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
resolveHighRefreshMode()
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
@@ -169,11 +154,9 @@ class MainActivity : ComponentActivity() {
}
} else {
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
@@ -229,7 +212,6 @@ class MainActivity : ComponentActivity() {
lastNavDir = dir
if (dir != 0) {
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
lastPadStyle = Gamepad.styleFor(event.device)
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true
@@ -55,23 +55,13 @@ data class Settings(
*/
val libraryEnabled: Boolean = true,
/**
* "Low-latency mode (experimental)" — the master switch over the latency overhaul: decoder
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, HDMI ALLM, and the
* forced TV mode switch. (The Wi-Fi locks are NOT part of this — both are always held while
* streaming; see StreamScreen.) Off (default): the original decode pipeline, kept as the
* known-good baseline until the aggressive stack is proven per-device.
* Aggressive decoder latency tuning — the master escape hatch. On (default): the decoder runs
* the full low-latency profile (per-SoC vendor keys + max-clock operating-rate on Qualcomm).
* Off: a conservative profile (the standard `low-latency` key only), for a device that thermally
* throttles or misbehaves under the aggressive clocks. Decoder ranking, the Wi-Fi low-latency
* lock and HDMI game-mode signalling stay on regardless — they're harmless.
*/
val lowLatencyMode: Boolean = false,
/**
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
* for it to reappear (see [WakeController]) before dialing. Off: always dial straight through,
* skipping the mDNS-presence check entirely — for a host that's actually up but not visible on
* mDNS (a flaky discovery path, a VLAN/subnet that blocks multicast, etc.), where auto-wake would
* otherwise misfire and wait out its timeout despite the host already being reachable.
*/
val autoWakeEnabled: Boolean = true,
val lowLatencyMode: Boolean = true,
)
/** [Settings.touchMode] values; persisted by name. */
@@ -100,8 +90,7 @@ class SettingsStore(context: Context) {
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, false),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
)
fun save(s: Settings) {
@@ -121,7 +110,6 @@ class SettingsStore(context: Context) {
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.apply()
}
@@ -140,15 +128,7 @@ class SettingsStore(context: Context) {
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_LIBRARY = "library_enabled"
/**
* Deliberately NOT the original `"low_latency_mode"` key: that one shipped default-ON, so
* any install that ever saved settings persisted `true` — under the old key, flipping the
* default to off would leave exactly the regressed devices stuck on the overhaul. The fresh
* key restarts everyone at the safe default; the stale one is abandoned unread.
*/
const val K_LOW_LATENCY = "low_latency_mode_experimental"
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_LOW_LATENCY = "low_latency_mode"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode"
@@ -246,10 +226,6 @@ val BITRATE_OPTIONS = listOf(
20_000 to "20 Mbps",
50_000 to "50 Mbps",
100_000 to "100 Mbps",
150_000 to "150 Mbps",
200_000 to "200 Mbps",
300_000 to "300 Mbps",
500_000 to "500 Mbps",
)
/** index = CompositorPref wire byte. */
@@ -326,10 +326,9 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
) { c -> update(s.copy(compositor = c)) }
ToggleRow(
title = "Low-latency mode (experimental)",
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
"decode, HDMI game mode). Can lower latency, but may stutter or glitch on " +
"some devices — turn off if the stream misbehaves.",
title = "Low-latency mode",
subtitle = "Run the decoder at max clocks for the lowest latency. Turn off only if a " +
"device overheats or glitches during long sessions.",
checked = s.lowLatencyMode,
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
)
@@ -395,14 +394,6 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
checked = s.libraryEnabled,
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
)
ToggleRow(
title = "Auto-wake on connect",
subtitle = "Send Wake-on-LAN and wait for a saved host to reappear on mDNS before " +
"connecting. Turn off if a host that's already on isn't seen on mDNS, so connects " +
"go straight through instead of waiting out the wake timeout.",
checked = s.autoWakeEnabled,
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
)
ToggleRow(
title = "Stats overlay",
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
@@ -6,11 +6,9 @@ import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Build
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -65,10 +63,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val touchMode = initialSettings.touchMode
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
// default) runs the original decode pipeline; on enables the aggressive stack — decoder
// ranking + vendor keys + async loop (native side), HDMI ALLM below, game-tagged audio, and
// DSCP marking (applied earlier, at connect).
// Master low-latency toggle, resolved once for the session and passed to the decoder at start.
val lowLatencyMode = initialSettings.lowLatencyMode
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
@@ -87,75 +82,33 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
}
}
// Host-gone watchdog. When the host suspends/sleeps (or crashes, or drops off the network) it
// stops answering the QUIC keep-alive and the connection idle-times out (~8 s) — no more frames
// arrive and the decoder would otherwise sit frozen on its last decoded frame until the user
// manually backed out. Poll the native session-liveness flag (one atomic load, independent of the
// stats HUD) and, the moment the session is dead, drop back to the menu so the user can
// Wake-on-LAN the host instead of being stranded on a frozen picture. Mirrors the Apple client's
// onSessionEnd → sessionEnded() → disconnect(). The 1 s cadence + the ~8 s idle timeout is a
// deliberately generous window: the keep-alive holds a merely-quiet connection (a static desktop)
// open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so
// it stops the moment we navigate away (the handle is only freed later, in onDispose).
LaunchedEffect(handle) {
while (true) {
delay(1000)
if (NativeBridge.nativeSessionEnded(handle)) {
Toast.makeText(
context,
"Connection lost — the host may be asleep. Wake it to reconnect.",
Toast.LENGTH_LONG,
).show()
onDisconnect()
return@LaunchedEffect
}
}
}
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
// way out, but `nativeClose` frees the handle — so once it's closed, NO path may touch the handle
// again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
val closed = remember { AtomicBoolean(false) }
// Wi-Fi locks held for the stream's duration — BOTH of them, unconditionally (Moonlight does
// the same). Without an effective lock, Wi-Fi power save batches downlink delivery into
// beacon-interval clumps: hundreds of ms of latency mush, sawtoothing bitrate, and periodic
// whole-frame loss when the AP's power-save buffer overflows (all observed live on a phone).
// - FULL_LOW_LATENCY (API 29+) is the only lock that actually disables power save on modern
// Android; it needs the app foreground + screen on, which a stream always is.
// - FULL_HIGH_PERF covers older releases — it is deprecated AND a documented no-op on recent
// Android, which is exactly why it can't be the only lock (a lesson learned: holding just
// HIGH_PERF left power save fully active on Android 13+).
// acquire() ENFORCES the WAKE_LOCK permission (manifest) — and a failed acquire MUST be loud:
// a silent runCatching hid the missing permission for weeks (dumpsys wifi showed
// low_latency_active_time_ms=0 across every "locked" stream). Non-reference-counted: one
// explicit acquire/release each.
val wifiLocks = remember(handle) {
// A Wi-Fi low-latency lock held for the stream's duration: asks the Wi-Fi firmware to drop its
// power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
// 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission
// beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
val wifiLock = remember(handle) {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
?: return@remember emptyList<WifiManager.WifiLock>()
buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "punktfunk:stream-ll")
?.let(::add)
}
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
} else {
@Suppress("DEPRECATION")
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "punktfunk:stream-hp")
?.let(::add)
}.onEach { it.setReferenceCounted(false) }
WifiManager.WIFI_MODE_FULL_HIGH_PERF
}
wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) }
}
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
wifiLocks.forEach { lock ->
runCatching { lock.acquire() }.onFailure { e ->
Log.w("punktfunk", "WifiLock acquire failed — power save stays ON: $lock", e)
}
}
runCatching { wifiLock?.acquire() }
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API
// 30+. Part of the experimental low-latency stack.
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API 30+.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(true)
}
controller?.let {
@@ -171,9 +124,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop.
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
activity?.requestStreamExit = onDisconnect // Select+Start+L1+R1 chord leaves the stream
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
val feedback = GamepadFeedback(handle).also { it.start() }
@@ -187,10 +138,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(false)
}
wifiLocks.forEach { runCatching { if (it.isHeld) it.release() } }
runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
// Release the landscape lock so the rest of the app follows the device/system again.
activity?.requestedOrientation =
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -202,8 +153,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
}
}
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
BackHandler { onDisconnect() }
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
@@ -212,13 +162,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
SurfaceView(ctx).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
// Low-latency mode: rank MediaCodecList decoders for the negotiated
// MIME (framework-only API) and hand the chosen one to Rust, which
// creates it by name and applies the per-SoC vendor low-latency keys.
// Off ⇒ no ranking: the platform resolves its default decoder for the
// MIME, exactly as before the overhaul.
// Rank MediaCodecList decoders for the negotiated MIME (framework-only
// API) and hand the chosen one to Rust, which creates it by name and
// applies the per-SoC vendor low-latency keys.
val mime = NativeBridge.nativeVideoMime(handle)
val choice = if (lowLatencyMode) VideoDecoders.pickDecoder(mime) else null
val choice = VideoDecoders.pickDecoder(mime)
NativeBridge.nativeStartVideo(
handle,
holder.surface,
@@ -227,7 +175,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
choice?.lowLatencyFeature ?: false,
isTv,
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
NativeBridge.nativeStartAudio(handle)
if (micWanted) NativeBridge.nativeStartMic(handle)
}
@@ -187,19 +187,12 @@ internal fun StreamScene() {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
),
) {
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer,
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full
// split equation, and the decoder label line shows the ranked low-latency decoder.
// [fps, mbps, latP50, latP95, latValid, skew, w, h, hz, dropped,
// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc] — the last four = a 10-bit
// BT.2020 PQ (HDR) 4:2:0 feed, so the HUD renders its video-feed line.
StatsOverlay(
doubleArrayOf(
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0,
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
),
decoderLabel = "c2.qti.hevc.decoder · low-latency",
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
doubleArrayOf(238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 10.0, 9.0, 16.0, 1.0),
Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
}
+1 -11
View File
@@ -110,18 +110,8 @@ afterEvaluate {
// screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so
// CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired
// for every normal APK/AAR build.
//
// DEBUG APKs SHIP RELEASE RUST. Cargo's debug profile is not "a bit slower" for this library —
// it is unusable: the AES-GCM data-plane decrypt runs through generic-array iterator closures
// with per-byte UB checks instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf):
// ~800 µs of user CPU per 1.4 KB packet, the receive pump pinned over a full core yet unable to
// drain a 20 Mbps stream — every debug-APK on-device test was silently benchmarking unoptimized
// crypto, not the streaming pipeline. Kotlin debuggability is untouched (the APK is still a
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
// build for the rare session that actually steps through Rust.
if (!project.hasProperty("skipRustBuild")) {
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease
tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) }
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
}
}
@@ -57,7 +57,6 @@ object Gamepad {
private const val VID_SONY = 0x054C
private const val VID_MICROSOFT = 0x045E
private const val VID_VALVE = 0x28DE
private const val VID_NINTENDO = 0x057E
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
@@ -99,28 +98,6 @@ object Gamepad {
}
}
/**
* The glyph family a controller's physical buttons belong to, for the console UI's hint bar —
* so a DualSense user sees ✕/○/□/△ shapes and a Switch pad its monochrome lettering instead of
* Xbox's coloured letters. PURELY visual: the wire mapping ([buttonBit]) is unaffected.
*/
enum class PadStyle { GENERIC, XBOX, PLAYSTATION, NINTENDO }
/**
* Resolve the [PadStyle] for a connected controller by USB vendor id. Vendor alone is enough —
* every pad a vendor ships wears its family's glyphs (any Sony pad has the shapes, any Nintendo
* pad the /+ system buttons), so unlike [prefFor] no PID table is needed. Valve renders as
* [PadStyle.XBOX]: Steam pads carry A/B/X/Y in Xbox positions. Unknown vendors (8BitDo & co.,
* which near-universally clone the Xbox layout) fall back to [PadStyle.GENERIC], drawn with the
* Xbox convention.
*/
fun styleFor(dev: InputDevice?): PadStyle = when (dev?.vendorId) {
VID_SONY -> PadStyle.PLAYSTATION
VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX
VID_NINTENDO -> PadStyle.NINTENDO
else -> PadStyle.GENERIC
}
/** True when [dev]'s source classes include gamepad or joystick. */
fun isPad(dev: InputDevice?): Boolean {
val s = dev?.sources ?: return false
@@ -51,23 +51,11 @@ object NativeBridge {
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
preferredCodec: Int,
timeoutMs: Int,
/** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game,
* or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */
launch: String?,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
external fun nativeHostFingerprint(handle: Long): String
/**
* Has the underlying QUIC session ended? `true` once the connection closed — a host suspend /
* crash / network drop idle-timed it out (~8 s), or the host closed it — from then on no frame
* ever arrives and the video sits frozen on its last one. The stream watchdog polls this (~1 Hz)
* to leave a dead stream and return to the menu, where the user can Wake-on-LAN the host, instead
* of stranding them on a frozen frame. `false` on a `0` handle. Cheap (one atomic load); UI-safe.
*/
external fun nativeSessionEnded(handle: Long): Boolean
/**
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
@@ -82,14 +70,6 @@ object NativeBridge {
name: String,
): String
/**
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
* linger for a reconnect. Call from an explicit disconnect gesture only — NOT from a
* host-ended/network-drop end or an app-background (those keep the linger). No-op on `0`.
*/
external fun nativeDisconnectQuit(handle: Long)
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
external fun nativeClose(handle: Long)
@@ -123,15 +103,6 @@ object NativeBridge {
*/
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
/**
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
* defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE
* [nativeConnect] (the tag is applied at socket creation); `HostConnect.connectToHost` does.
* The rest of the toggle rides explicit per-session parameters ([nativeStartVideo] /
* [nativeStartAudio]). Cheap (one atomic store); UI-safe.
*/
external fun nativeSetLowLatencyMode(enabled: Boolean)
/**
* The MediaCodec MIME the host resolved for this session (`"video/hevc"` / `"video/avc"` /
* `"video/av01"`), or `""` on a `0` handle. Kotlin ranks `MediaCodecList` decoders for this
@@ -143,12 +114,10 @@ object NativeBridge {
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
* the MIME — what the pre-overhaul client always did); [lowLatencyMode] is the user's
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
* hint otherwise). No-op if already started.
* the MIME); [lowLatencyMode] is the user's master toggle (default on → aggressive per-SoC
* tuning; off → conservative); [lowLatencyFeature] is whether [decoderName] advertised
* `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI mode switch to the stream
* refresh on TV boxes (vs. the softer seamless hint on phones). No-op if already started.
*/
external fun nativeStartVideo(
handle: Long,
@@ -194,12 +163,10 @@ object NativeBridge {
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
/**
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust.
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
* HAL's game-audio routing. No-op if already started. Best-effort — a failure leaves video
* streaming.
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. No-op
* if already started. Best-effort — a failure leaves video streaming.
*/
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean)
external fun nativeStartAudio(handle: Long)
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
external fun nativeStopAudio(handle: Long)
@@ -57,17 +57,7 @@ object VideoDecoders {
val name = info.name
val lower = name.lowercase()
if (BLOCKED_PREFIXES.any { lower.startsWith(it) } || lower in BLOCKED_EXACT) continue
// Never a secure decoder: `.secure` names are the DRM-pipeline twins of the real
// decoder and require a secure surface — configuring one for a clear stream fails (or
// renders black). The plain twin is also in the list, so drop rather than rank
// (a `.secure` twin can otherwise OUT-score its plain sibling when only it advertises
// FEATURE_LowLatency). Moonlight filters the same way.
if (lower.endsWith(".secure")) continue
val caps = runCatching { info.getCapabilitiesForType(mime) }.getOrNull() ?: continue
val secureRequired = runCatching {
caps.isFeatureRequired(CodecCapabilities.FEATURE_SecurePlayback)
}.getOrDefault(false)
if (secureRequired) continue
val hardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
info.isHardwareAccelerated
-8
View File
@@ -31,14 +31,6 @@ mdns-sd = "0.20"
# via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets.
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14"
# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only
# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer
# capped well below target") reach logcat only via tracing's "log" feature, which forwards events
# as `log` records when no tracing subscriber is set (always, here). Today that feature happens to
# be enabled transitively — quinn's default `log` feature unifies `tracing/log` onto the whole
# graph — but nothing about this client's logging should hinge on a QUIC crate's default feature
# set, so declare it explicitly.
tracing = { version = "0.1", default-features = false, features = ["std", "log"] }
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
+2 -6
View File
@@ -103,10 +103,8 @@ pub struct HintSession {
impl HintSession {
/// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds.
/// `None` when ADPF is unavailable (device API < 33) or the platform declines — the caller then
/// runs unhinted (a no-op, not an error). `prefer_performance` (the experimental low-latency
/// mode) additionally biases the governor away from power efficiency (API 35+); off, the
/// session runs with the platform default, as it did before the overhaul.
pub fn create(target_ns: i64, tids: &[i32], prefer_performance: bool) -> Option<Self> {
/// runs unhinted (a no-op, not an error).
pub fn create(target_ns: i64, tids: &[i32]) -> Option<Self> {
if target_ns <= 0 || tids.is_empty() {
return None;
}
@@ -121,12 +119,10 @@ impl HintSession {
// Tell the governor NOT to bias this session toward power efficiency (API 35+): our loop is
// latency-critical, so we want it kept on fast cores at high clocks over battery savings.
// Best-effort; absent below API 35.
if prefer_performance {
if let Some(f) = api.set_prefer_power_efficiency {
// SAFETY: `session` is the live session just created; the fn takes it + a bool.
unsafe { f(session, false) };
}
}
Some(Self { api, session })
}
+5 -14
View File
@@ -116,10 +116,8 @@ pub struct AudioPlayback {
impl AudioPlayback {
/// Open AAudio (LowLatency, 48 kHz/f32, the host-resolved channel layout) with a realtime
/// callback draining a jitter ring, then spawn the Opus decode thread. `None` on failure (the
/// caller leaves video streaming). `game_audio` (the experimental low-latency mode) tags the
/// stream usage=Game for the HAL's game-audio routing; off, the stream is untagged as it was
/// before the overhaul.
pub fn start(client: Arc<NativeClient>, game_audio: bool) -> Option<AudioPlayback> {
/// caller leaves video streaming).
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
// Build playback from the host-RESOLVED channel count (never the request): 2 = stereo /
// 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
let channels = punktfunk_core::audio::normalize_channels(client.audio_channels) as usize;
@@ -228,7 +226,7 @@ impl AudioPlayback {
AudioCallbackResult::Continue
};
let builder = AudioStreamBuilder::new()?
let stream = AudioStreamBuilder::new()?
.direction(AudioDirection::Output)
.sample_rate(SAMPLE_RATE)
// The wire order (FL FR FC LFE RL RR SL SR) is the standard AAudio/Android channel
@@ -236,19 +234,12 @@ impl AudioPlayback {
// from `channel_count` (the ndk crate's builder exposes no setChannelMask); the host
// captures + Opus-encodes in exactly this order.
.channel_count(channels as i32)
.format(AudioFormat::PCM_Float);
.format(AudioFormat::PCM_Float)
// Tag the stream as game audio (usage=Game / content=Movie): the audio HAL applies
// its low-latency game-audio routing/policy and it's grouped correctly with the
// game-mode profile. Advisory — ignored where the device has no such policy. Part of
// the experimental low-latency stack; off, the stream stays untagged.
let builder = if game_audio {
builder
// game-mode profile. Advisory — ignored where the device has no such policy.
.usage(AudioUsage::Game)
.content_type(AudioContentType::Movie)
} else {
builder
};
let stream = builder
.performance_mode(AudioPerformanceMode::LowLatency)
.sharing_mode(sharing)
.data_callback(Box::new(callback))
+20 -48
View File
@@ -36,11 +36,9 @@ const IN_FLIGHT_CAP: usize = 64;
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
const PENDING_SPLIT_CAP: usize = 256;
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
/// the user's "Low-latency mode (experimental)" toggle is ON — off, the sync loop always runs (the
/// original pipeline).
/// Whether to run the event-driven async decode loop (default) or the synchronous poll loop kept as
/// a bring-up fallback. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop
/// presents a decoded frame the instant it's ready instead of waiting out a poll interval.
const USE_ASYNC_DECODE: bool = true;
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
@@ -52,10 +50,8 @@ pub(crate) struct DecodeOptions {
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
pub ll_feature: bool,
/// The user's "Low-latency mode (experimental)" master toggle. On ⇒ the full overhaul: async
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
/// mode switch. Off (default) ⇒ the original pre-overhaul pipeline, kept as the known-good
/// baseline while the overhaul is experimental.
/// The user's "Low-latency mode" master toggle (default on ⇒ full aggressive profile; off ⇒
/// conservative, an escape hatch for a device that throttles under the clocks).
pub low_latency_mode: bool,
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
@@ -71,16 +67,17 @@ pub fn run(
stats: Arc<crate::stats::VideoStats>,
opts: DecodeOptions,
) {
if opts.low_latency_mode && USE_ASYNC_DECODE {
if USE_ASYNC_DECODE {
run_async(client, window, shutdown, stats, opts);
} else {
run_sync(client, window, shutdown, stats, opts);
}
}
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
/// only blocking wait is a short output dequeue while input is backed up.
/// The synchronous poll loop — the original decode path, kept as a bring-up fallback behind
/// [`USE_ASYNC_DECODE`]. Feeds and drains on this one thread; the only blocking wait is a short
/// output dequeue while input is backed up.
#[allow(dead_code)]
fn run_sync(
client: Arc<NativeClient>,
window: NativeWindow,
@@ -163,11 +160,7 @@ fn run_sync(
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
// off, every form factor gets the original soft seamless hint.
if mode.refresh_hz > 0
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
{
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
log::debug!(
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
mode.refresh_hz
@@ -326,12 +319,8 @@ fn run_sync(
// or where the platform declines → `None`, and the loop runs unhinted).
hint_tried = true;
let tids = client.hot_thread_ids();
// The pump/audio priority boost is part of the experimental low-latency stack; the
// ADPF session itself predates it and always runs (max-performance bias gated inside).
if low_latency_mode {
boost_hot_threads(&tids);
}
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
log::info!(
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
if hint.is_some() {
@@ -407,15 +396,12 @@ fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
MediaCodec::from_decoder_type(mime)
}
/// Apply the low-latency MediaFormat keys for `codec_name`.
///
/// `aggressive` = the "Low-latency mode (experimental)" master toggle. **Off** (default) ⇒ the
/// pre-overhaul key set, byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor
/// twin, `priority = 0` AND `operating-rate = MAX` set together — kept as the known-good baseline
/// (the profile every device streamed with before the overhaul). **On** ⇒ the Moonlight-parity
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
/// Apply the low-latency MediaFormat keys for `codec_name`. The standard AOSP `low-latency` key is
/// always set (API 30+, harmless/ignored elsewhere). When `aggressive` (the "Low-latency mode"
/// master toggle) we additionally set MediaTek's `vdec-lowlatency` (unconditionally — ignored off
/// MediaTek), the per-SoC vendor extension keys (gated on the decoder-name prefix the way
/// Moonlight-Android does, since a key one vendor honours is meaningless on another), and one clock
/// hint. Off ⇒ the standard key only, a gentler profile for a device that throttles under max clocks.
///
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
/// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA
@@ -425,12 +411,6 @@ fn configure_low_latency(format: &mut MediaFormat, codec_name: &str, aggressive:
// Standard key: request the no-reorder low-latency path where the platform decoder supports it.
format.set_i32("low-latency", 1);
if !aggressive {
// The original profile: the Qualcomm vendor twin set blind (unknown keys are ignored by
// other vendors' codecs), realtime priority, and the AOSP "unbounded" operating-rate
// sentinel — decode each frame at max clocks rather than pacing to the frame rate.
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
format.set_i32("priority", 0); // 0 = realtime
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
return;
}
// MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes).
@@ -620,11 +600,7 @@ fn run_async(
mode.width,
mode.height
);
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
// off, every form factor gets the original soft seamless hint.
if mode.refresh_hz > 0
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
{
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
log::debug!(
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
mode.refresh_hz
@@ -740,12 +716,8 @@ fn run_async(
if !hint_tried {
hint_tried = true;
let tids = client.hot_thread_ids();
// The pump/audio priority boost is part of the experimental low-latency stack; the
// ADPF session itself predates it and always runs (max-performance bias gated inside).
if low_latency_mode {
boost_hot_threads(&tids);
}
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
log::info!(
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
if hint.is_some() {
+1 -4
View File
@@ -44,10 +44,7 @@ mod stats;
mod wol;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn JNI_OnLoad(
+2 -71
View File
@@ -32,23 +32,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
}
}
/// `NativeBridge.nativeSetLowLatencyMode(enabled)` — apply the user's "Low-latency mode
/// (experimental)" toggle to the process-wide transport defaults, today just DSCP/QoS marking on
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
_env: JNIEnv,
_this: JObject,
enabled: jboolean,
) {
punktfunk_core::transport::set_dscp_default(enabled != 0);
}
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`.
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs): Long`.
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
@@ -78,7 +63,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio_channels: jint,
preferred_codec: jint,
timeout_ms: jint,
launch: JString<'local>,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
@@ -90,13 +74,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
.unwrap_or_default();
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game;
// null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
let launch: Option<String> = env
.get_string(&launch)
.map(Into::into)
.ok()
.filter(|s: &String| !s.is_empty());
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
None
@@ -147,7 +124,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
preferred_codec.clamp(0, u8::MAX as jint) as u8,
launch, // a store-qualified library id to boot into a game, or None for the desktop
None, // launch: default app
pin, // Some → Crypto on host-fp mismatch
identity, // owned (cert, key) PEM, or None (anonymous)
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
@@ -193,30 +170,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
})
}
/// `NativeBridge.nativeDisconnectQuit(handle)` — signal a DELIBERATE user quit before `nativeClose`,
/// so the session closes with `QUIT_CLOSE_CODE` and the host tears it down immediately instead of
/// holding the keep-alive linger for a reconnect. Call from an explicit disconnect action only (a
/// plain drop / app-background keeps the linger). The handle is only BORROWED (not freed). No-op on `0`.
///
/// # Safety contract
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` — we only borrow it
// (no drop), so it stays owned by Kotlin for the later `nativeClose`.
let sh = unsafe { &*(handle as *const SessionHandle) };
sh.client.disconnect_quit();
}
})
}
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
/// connect. `""` on a `0` handle.
@@ -239,28 +192,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
}
}
/// `NativeBridge.nativeSessionEnded(handle): Boolean` — has the underlying QUIC session ended?
/// `true` once the connection closed (a host suspend / crash / network drop idle-timed it out, or the
/// host closed it) — from then on no more frames arrive and the video sits frozen on its last one.
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
/// handle. Cheap (one atomic load); safe on the UI thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) -> jboolean {
jni_guard(0, || {
if handle == 0 {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
jboolean::from(h.client.is_session_ended())
})
}
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
+3 -6
View File
@@ -233,17 +233,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
})
}
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
/// streaming.
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
low_latency_mode: jboolean,
) {
if handle == 0 {
return;
@@ -254,7 +251,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
if guard.is_some() {
return; // already playing
}
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode != 0) {
match crate::audio::AudioPlayback::start(h.client.clone()) {
Some(p) => *guard = Some(p),
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
}
@@ -432,7 +432,6 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -472,7 +471,6 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -276,10 +276,7 @@ final class SessionModel: ObservableObject {
disconnect()
}
/// Tear the session down. `deliberate` (the default) means a user-initiated quit signal
/// `disconnectQuit()` so the host skips the keep-alive linger; `sessionEnded()` (a host-ended /
/// dropped session) passes `false` to leave the linger intact.
func disconnect(deliberate: Bool = true) {
func disconnect() {
statsTimer?.invalidate()
statsTimer = nil
let audio = self.audio
@@ -297,8 +294,6 @@ final class SessionModel: ObservableObject {
Task.detached {
audio?.stop()
feedback?.stop()
// Deliberate user quit tell the host to skip the keep-alive linger (must precede close).
if deliberate { conn.disconnectQuit() }
conn.close()
}
} else {
@@ -326,7 +321,7 @@ final class SessionModel: ObservableObject {
func sessionEnded() {
guard connection != nil else { return }
let name = activeHost?.displayName ?? "host"
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
disconnect()
errorMessage = "Session ended by \(name)."
}
@@ -759,17 +759,6 @@ public final class PunktfunkConnection {
_ = punktfunk_connection_send_input(h, &ev)
}
/// Signal a **deliberate** user-initiated quit before ``close()``: the connection closes with
/// `QUIT_CLOSE_CODE` (81) so the host tears the session down immediately instead of holding the
/// keep-alive linger for a reconnect. Call only from an explicit "Disconnect" action NOT from a
/// network drop / host-ended / app-background (those keep the linger). Idempotent, safe pre-close.
public func disconnectQuit() {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
punktfunk_connection_disconnect_quit(h)
}
/// Close the connection and free the handle. Safe from any thread, idempotent; waits
/// for in-flight pulls ( their timeouts) before tearing down.
public func close() {
@@ -84,6 +84,15 @@ public final class InputCapture {
/// its Esc suppression need it in both states).
private var cmdKeysDown: Set<UInt32> = []
#if os(macOS)
/// Previous raw `NSEvent.modifierFlags.rawValue` (LOW 16 bits intact those carry the
/// device-dependent L/R bits). Modifier keys never fire keyDown/keyUp on macOS; they
/// arrive as flagsChanged, which doesn't carry down-vs-up we recover that by diffing
/// this snapshot. Resynced (not diffed) while forwarding is off so a modifier held
/// across a capture toggle can't produce a phantom transition on re-engage.
private var prevModFlags: UInt = 0
#endif
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
/// locally; while false the user is interacting with the local UI (dragging the
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
@@ -270,6 +279,12 @@ public final class InputCapture {
residualY = 0
residualScrollX = 0
residualScrollY = 0
#if os(macOS)
// Drop the modifier snapshot too: a flagsChanged transition can be missed if focus
// leaves mid-chord, and the next handleFlagsChanged resyncs from a clean slate (it
// resyncs while released anyway, but this keeps stuck state from outliving a blur).
prevModFlags = 0
#endif
}
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
@@ -344,52 +359,39 @@ public final class InputCapture {
}
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp they arrive
/// as flagsChanged, which carries no down-vs-up. `keyCode` names the key that changed
/// (kVK_Control & co., already L/R-specific); `resolveModifier` recovers the direction
/// from the flags. Fed `event.keyCode` + `UInt(event.modifierFlags.rawValue)` LOW 16
/// bits intact, they carry the device-dependent L/R bits (the .deviceIndependentFlagsMask
/// the monitor uses deliberately strips exactly these do NOT pre-mask here).
public func handleFlagsChanged(keyCode: UInt16, rawFlags: UInt) {
if inputDebug {
inputLog.debug(
"flagsChanged keyCode \(keyCode, privacy: .public) flags 0x\(String(rawFlags, radix: 16), privacy: .public) forwarding \(self.forwarding, privacy: .public)")
/// as flagsChanged, which carries no down-vs-up. We diff the raw flags against the prior
/// snapshot to recover each transition, and the changed key's L/R identity from the
/// device-dependent bits in the LOW 16 bits (the .deviceIndependentFlagsMask the
/// monitor uses deliberately strips exactly these do NOT pre-mask here). Each side maps
/// to the same L/R modifier VK `hidToVK` already emits, so the host needs no change.
/// Fed `UInt(event.modifierFlags.rawValue)`.
public func handleFlagsChanged(_ rawFlags: UInt) {
// While released we only resync the snapshot, so a modifier held across a capture
// toggle doesn't show up as a spurious transition the moment forwarding re-engages.
guard forwarding else {
prevModFlags = rawFlags
return
}
guard forwarding else { return }
guard let (vk, down) = Self.resolveModifier(
keyCode: keyCode, rawFlags: rawFlags, isDown: { pressedVKs.contains($0) })
else { return } // Fn / Caps Lock / unknown nothing the host consumes on this path
// (device-dependent mask, VK). LOW-16-bit masks from IOLLEvent.h (NX_DEVICE*MASK):
// Lshift 0x2 Rshift 0x4 | Lctrl 0x1 Rctrl 0x2000 | Lalt 0x20 Ralt 0x40 | Lcmd 0x8 Rcmd 0x10.
let table: [(UInt, UInt32)] = [
(0x2, 0xA0), (0x4, 0xA1), // VK_LSHIFT / VK_RSHIFT
(0x1, 0xA2), (0x2000, 0xA3), // VK_LCONTROL / VK_RCONTROL
(0x20, 0xA4), (0x40, 0xA5), // VK_LMENU / VK_RMENU (left/right alt-option)
(0x8, 0x5B), (0x10, 0x5C), // VK_LWIN / VK_RWIN (left/right command)
]
for (mask, vk) in table {
let now = (rawFlags & mask) != 0
let was = (prevModFlags & mask) != 0
guard now != was else { continue }
// Keep cmdKeysDown in step (the toggle + Esc suppression read it); sendKey
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
if vk == 0x5B || vk == 0x5C {
if down { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
if now { cmdKeysDown.insert(vk) } else { cmdKeysDown.remove(vk) }
}
sendKey(vk, down: down)
sendKey(vk, down: now)
}
/// Resolve one flagsChanged transition to (Windows VK, down). The changed key is
/// `keyCode`; the direction comes from the flags. The device-dependent L/R bits (LOW
/// 16 bits, NX_DEVICE*KEYMASK) disambiguate the two same-class keys, but some
/// keyboards ship flagsChanged WITHOUT them only the device-independent class
/// bit (NX_CONTROLMASK & co.) is set. A pure diff of the device bits silently drops
/// those keys (seen live: Control never forwarded), so this is keyCode-driven with the
/// flags as evidence: class bit clear the key went up; device bits present they
/// say which side is held now; class bit set with NO device bits flip the held state
/// we track (`isDown`, from pressedVKs SDL ships the same fallback). Each keyCode
/// maps to the L/R modifier VK `hidToVK` already emits, so the host needs no change.
/// Returns nil for modifiers the host doesn't consume on this path (Fn, Caps Lock).
static func resolveModifier(
keyCode: UInt16, rawFlags: UInt, isDown: (UInt32) -> Bool
) -> (vk: UInt32, down: Bool)? {
guard let mod = modifierBits[keyCode] else { return nil }
let down: Bool
if rawFlags & mod.classMask == 0 {
down = false
} else if rawFlags & (mod.deviceBit | mod.siblingBit) != 0 {
down = rawFlags & mod.deviceBit != 0
} else {
down = !isDown(mod.vk)
}
return (mod.vk, down)
prevModFlags = rawFlags
}
#endif
@@ -98,23 +98,5 @@ extension InputCapture {
m[0x47] = 0x90 // KP clear sits where NumLock is VK_NUMLOCK. (KP equals 0x51 dropped.)
return m
}()
/// NSEvent.keyCode of each modifier key (kVK_Shift & co. modifiers arrive only as
/// flagsChanged) its Windows VK plus the `NSEvent.modifierFlags` bits that describe
/// it: `classMask` is the device-INDEPENDENT NX_*MASK for the modifier class,
/// `deviceBit`/`siblingBit` the device-dependent bits (LOW 16 bits, NX_DEVICE*KEYMASK
/// in IOLLEvent.h) for this key and its opposite-side twin. Consumed by
/// `resolveModifier`, which explains why both kinds of bit are needed.
static let modifierBits:
[UInt16: (vk: UInt32, classMask: UInt, deviceBit: UInt, siblingBit: UInt)] = [
56: (0xA0, 0x2_0000, 0x2, 0x4), // left shift VK_LSHIFT
60: (0xA1, 0x2_0000, 0x4, 0x2), // right shift VK_RSHIFT
59: (0xA2, 0x4_0000, 0x1, 0x2000), // left control VK_LCONTROL
62: (0xA3, 0x4_0000, 0x2000, 0x1), // right control VK_RCONTROL
58: (0xA4, 0x8_0000, 0x20, 0x40), // left option VK_LMENU
61: (0xA5, 0x8_0000, 0x40, 0x20), // right option VK_RMENU
55: (0x5B, 0x10_0000, 0x8, 0x10), // left command VK_LWIN
54: (0x5C, 0x10_0000, 0x10, 0x8), // right command VK_RWIN
]
#endif
}
@@ -346,13 +346,10 @@ public final class StreamLayerView: NSView {
super.keyUp(with: event)
}
/// Modifier keys (shift/control/option/command) arrive ONLY as flagsChanged on macOS,
/// never keyDown/keyUp the changed key is `event.keyCode`; InputCapture resolves the
/// down-vs-up direction from the flags (diffing the device-dependent flag bits alone
/// proved unreliable some keyboards omit them, which silently dropped Control).
/// never keyDown/keyUp InputCapture diffs the raw flags to recover each L/R down/up.
public override func flagsChanged(with event: NSEvent) {
if captured, let inputCapture {
inputCapture.handleFlagsChanged(
keyCode: event.keyCode, rawFlags: UInt(event.modifierFlags.rawValue))
inputCapture.handleFlagsChanged(UInt(event.modifierFlags.rawValue))
return
}
super.flagsChanged(with: event)
@@ -1,87 +0,0 @@
#if os(macOS)
import XCTest
@testable import PunktfunkKit
/// Pins the macOS flagsChanged modifier-VK resolution (InputCapture.resolveModifier).
/// Modifier keys arrive only as flagsChanged, which carries no down-vs-up: the changed key
/// is the event's keyCode, and the direction is recovered from the flag bits with a
/// held-state fallback for keyboards that omit the device-dependent L/R bits (the gap that
/// used to silently drop Control when the transition was diffed from those bits alone).
final class ModifierResolveTests: XCTestCase {
/// Resolve with a fixed already-held answer for the fallback path.
private func resolve(
keyCode: UInt16, rawFlags: UInt, held: Bool = false
) -> (vk: UInt32, down: Bool)? {
InputCapture.resolveModifier(keyCode: keyCode, rawFlags: rawFlags) { _ in held }
}
// MARK: Keyboards that report the device-dependent L/R bits (the common case)
func testControlPressAndReleaseWithDeviceBits() {
// Real left-Control down: NX_CONTROLMASK | NX_DEVICELCTLKEYMASK (+ misc low bits).
let down = resolve(keyCode: 59, rawFlags: 0x4_0101)
XCTAssertEqual(down?.vk, 0xA2) // VK_LCONTROL
XCTAssertEqual(down?.down, true)
// Release: the class mask is gone entirely.
let up = resolve(keyCode: 59, rawFlags: 0x100)
XCTAssertEqual(up?.vk, 0xA2)
XCTAssertEqual(up?.down, false)
}
func testRightControlUsesItsOwnDeviceBit() {
let down = resolve(keyCode: 62, rawFlags: 0x4_2000)
XCTAssertEqual(down?.vk, 0xA3) // VK_RCONTROL
XCTAssertEqual(down?.down, true)
}
func testReleasingOneOfTwoHeldControls() {
// Left goes up while right stays held: class mask still set, right device bit
// still set, LEFT device bit cleared the left key must resolve as UP.
let leftUp = resolve(keyCode: 59, rawFlags: 0x4_2000, held: true)
XCTAssertEqual(leftUp?.vk, 0xA2)
XCTAssertEqual(leftUp?.down, false)
}
func testEverySideMapsToItsOwnVK() {
XCTAssertEqual(resolve(keyCode: 56, rawFlags: 0x2_0002)?.vk, 0xA0) // VK_LSHIFT
XCTAssertEqual(resolve(keyCode: 60, rawFlags: 0x2_0004)?.vk, 0xA1) // VK_RSHIFT
XCTAssertEqual(resolve(keyCode: 58, rawFlags: 0x8_0020)?.vk, 0xA4) // VK_LMENU
XCTAssertEqual(resolve(keyCode: 61, rawFlags: 0x8_0040)?.vk, 0xA5) // VK_RMENU
XCTAssertEqual(resolve(keyCode: 55, rawFlags: 0x10_0008)?.vk, 0x5B) // VK_LWIN
XCTAssertEqual(resolve(keyCode: 54, rawFlags: 0x10_0010)?.vk, 0x5C) // VK_RWIN
for (_, down) in [56, 60, 58, 61, 55, 54].compactMap({
self.resolve(keyCode: UInt16($0), rawFlags: 0xFF_FFFF)
}) {
XCTAssertTrue(down)
}
}
// MARK: Keyboards that DON'T report the device bits (the bug this resolver fixes)
func testControlPressWithoutDeviceBitsFallsBackToHeldState() {
// Only NX_CONTROLMASK, no low bits at all: a flag diff of the device bits sees no
// transition and drops the key the fallback must infer DOWN from "not held yet".
let down = resolve(keyCode: 59, rawFlags: 0x4_0000, held: false)
XCTAssertEqual(down?.vk, 0xA2)
XCTAssertEqual(down?.down, true)
// And the mirror release (class cleared) still resolves as UP.
let up = resolve(keyCode: 59, rawFlags: 0, held: true)
XCTAssertEqual(up?.down, false)
}
func testClassBitStillSetButKeyAlreadyHeldResolvesUp() {
// Device-bit-less keyboard, second same-class key still holding the class bit:
// the best available answer for the key that changed is to flip its held state.
let up = resolve(keyCode: 59, rawFlags: 0x4_0000, held: true)
XCTAssertEqual(up?.down, false)
}
// MARK: Modifiers the host doesn't consume on this path
func testFnAndCapsLockResolveToNothing() {
XCTAssertNil(resolve(keyCode: 63, rawFlags: 0x80_0000)) // Fn / Globe
XCTAssertNil(resolve(keyCode: 57, rawFlags: 0x1_0000)) // Caps Lock
}
}
#endif
+3 -15
View File
@@ -154,21 +154,13 @@ pub fn run() -> glib::ExitCode {
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
}
let app = builder.build();
// One SDL context for the whole process: `activate` fires again on every subsequent
// launch forwarded to this already-running singleton (another `--connect`, the desktop
// icon clicked twice, …). SDL only ever lets the FIRST thread that calls `sdl3::init()`
// hold the "main thread" — a second `GamepadService::start()` from a later `activate`
// would spawn a new thread that fails that check forever. Starting it once here and
// cloning it into each `build_ui` keeps the worker thread (and its pad state) shared
// across every window instead.
let gamepad = crate::gamepad::GamepadService::start();
app.connect_activate(move |gtk_app| build_ui(gtk_app, gamepad.clone()));
app.connect_activate(build_ui);
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
// keeps GApplication from rejecting unknown options.
app.run_with_args(&[] as &[&str])
}
fn build_ui(gtk_app: &adw::Application, gamepad: crate::gamepad::GamepadService) {
fn build_ui(gtk_app: &adw::Application) {
let identity = match crate::trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
@@ -211,7 +203,7 @@ fn build_ui(gtk_app: &adw::Application, gamepad: crate::gamepad::GamepadService)
toasts,
settings: Rc::new(RefCell::new(Settings::load())),
identity,
gamepad,
gamepad: crate::gamepad::GamepadService::start(),
busy: std::cell::Cell::new(false),
fullscreen,
// (`--browse` makes cli_connect_request None — browse mode returns to the
@@ -250,10 +242,6 @@ fn build_ui(gtk_app: &adw::Application, gamepad: crate::gamepad::GamepadService)
let app = app.clone();
Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req))
},
on_wake_connect: {
let app = app.clone();
Rc::new(move |req| crate::ui_trust::wake_and_connect(app.clone(), req))
},
on_speed_test: {
let app = app.clone();
Rc::new(move |req| speed_test(app.clone(), req))
-20
View File
@@ -168,26 +168,6 @@ pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
let _ = known.save();
}
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
/// fingerprint). No-op — and no disk write — when unchanged. Called from the wake-and-wait flow when
/// a woken host reappears on a different IP than the stored one, so this and future connects dial the
/// live address instead of the stale one.
pub fn rekey_addr(fp_hex: &str, addr: &str, port: u16) {
if fp_hex.is_empty() {
return;
}
let mut known = KnownHosts::load();
let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else {
return;
};
if h.addr == addr && h.port == port {
return;
}
h.addr = addr.to_string();
h.port = port;
let _ = known.save();
}
/// Stamp "now" as this host's last successful connect (drives the hosts page's
/// most-recent accent). No-op when the fingerprint isn't stored.
pub fn touch_last_used(fp_hex: &str) {
+6 -72
View File
@@ -48,9 +48,6 @@ impl ConnectRequest {
/// the library browser).
pub struct HostsCallbacks {
pub on_connect: Rc<dyn Fn(ConnectRequest)>,
/// Connect to an OFFLINE saved host with a known MAC: wake it, poll until it's up (re-keying a
/// new DHCP IP), then connect. Falls back to `on_connect` when there's nothing to wake.
pub on_wake_connect: Rc<dyn Fn(ConnectRequest)>,
pub on_speed_test: Rc<dyn Fn(ConnectRequest)>,
pub on_pair: Rc<dyn Fn(ConnectRequest)>,
pub on_library: Rc<dyn Fn(ConnectRequest)>,
@@ -162,20 +159,9 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
// the child's own `activate` signal — so bridge it back to the child, where each card wires
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
//
// `child.activate()` in turn runs `GtkFlowBoxChild`'s own default handler, which re-emits
// `child-activated` on the FlowBox — bouncing straight back into this closure. Unguarded,
// that ping-pong recurses forever and overflows the stack on every single card click/Enter
// (a real crash seen live, not hypothetical); the re-entrancy flag breaks the cycle after
// the one real activation.
for flow in [&saved_flow, &disc_flow] {
let activating = std::cell::Cell::new(false);
flow.connect_child_activated(move |_, child| {
if activating.replace(true) {
return;
}
flow.connect_child_activated(|_, child| {
child.activate();
activating.set(false);
});
}
@@ -560,17 +546,15 @@ fn saved_card(
overlay.add_controller(right_click);
let on_connect = state.cbs.on_connect.clone();
let on_wake_connect = state.cbs.on_wake_connect.clone();
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, route to
// the wake-and-wait flow (send a magic packet, poll mDNS until it's up — re-keying a new DHCP IP —
// then connect). Otherwise a plain connect. A host that's genuinely off then times out as before.
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, fire a
// magic packet before connecting — the connect's own retry/timeout gives a woken host time to
// come up. A host that's genuinely off/unreachable then fails the connect as before.
let wake_first = !online && !req.mac.is_empty();
child.connect_activate(move |_| {
if wake_first {
on_wake_connect(req.clone());
} else {
on_connect(req.clone());
crate::wol::wake(&req.mac, req.addr.parse().ok());
}
on_connect(req.clone());
});
child
}
@@ -731,53 +715,3 @@ fn add_host_dialog(state: &Rc<State>) {
}
dialog.present(Some(&state.stack));
}
#[cfg(test)]
mod tests {
use adw::prelude::*;
use std::cell::Cell;
use std::rc::Rc;
// Reproduces the exact FlowBox/FlowBoxChild wiring from `new()`: `child-activated` bridges
// to `child.activate()`, whose own default handler re-emits `child-activated` on the
// FlowBox — that ping-pong recursed forever (stack overflow on every host-card click/Enter)
// until the re-entrancy guard was added. This exercises the *real* GTK signal cycle, not a
// simulation of it, so it fails the same way the shipped bug did if the guard regresses.
#[test]
#[ignore = "needs a Wayland/X display"]
fn flow_box_activation_bridge_does_not_recurse() {
assert!(gtk::init().is_ok(), "no display");
let flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.activate_on_single_click(true)
.build();
let activating = Cell::new(false);
flow.connect_child_activated(move |_, child| {
if activating.replace(true) {
return;
}
child.activate();
activating.set(false);
});
let child = gtk::FlowBoxChild::new();
flow.insert(&child, -1);
let fired = Rc::new(Cell::new(0u32));
{
let fired = fired.clone();
child.connect_activate(move |_| fired.set(fired.get() + 1));
}
// What a pointer click with `activate_on_single_click` does internally: emit
// `child-activated` directly on the FlowBox. A regression here overflows the stack
// instead of returning.
flow.emit_by_name::<()>("child-activated", &[&child]);
assert_eq!(
fired.get(),
1,
"the per-card handler should fire exactly once"
);
}
}
-10
View File
@@ -806,10 +806,6 @@ fn attach_keyboard(
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SHIFT_MASK;
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
tracing::info!(
captured = cap.captured.get(),
"chord: Ctrl+Alt+Shift+Q (release/engage)"
);
if cap.captured.get() {
cap.release();
} else {
@@ -820,11 +816,7 @@ fn attach_keyboard(
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
tracing::info!("chord: Ctrl+Alt+Shift+D (disconnect) — releasing capture + quitting");
cap.release();
// Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session down
// immediately instead of holding the keep-alive linger for a reconnect.
cap.connector.disconnect_quit();
stop_kb.store(true, Ordering::SeqCst);
return glib::Propagation::Stop;
}
@@ -1032,8 +1024,6 @@ fn spawn_disconnect_watch(
glib::spawn_future_local(async move {
if disconnect_rx.recv().await.is_ok() {
cap.release();
// Deliberate user exit (the controller escape chord) → QUIT_CLOSE_CODE, host skips linger.
cap.connector.disconnect_quit();
if window.is_fullscreen() {
window.unfullscreen();
}
-81
View File
@@ -60,87 +60,6 @@ pub fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
}
}
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet, then we poll
/// mDNS until it comes back online — re-sending every few seconds up to a timeout — and dial it via
/// [`initiate_connect`], **re-keying the saved record if the host woke on a new DHCP IP** (matched by
/// fingerprint). A "Waking…" dialog lets the user cancel. Mirrors the Apple/Android `HostWaker` (a
/// 90 s budget, resend every 6 s). The online path stays on the fast [`initiate_connect`]; this runs
/// only from the hosts page's auto-wake when a saved host isn't advertising.
pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
if app.busy.get() {
return;
}
let cancel = Rc::new(std::cell::Cell::new(false));
let waiting = adw::AlertDialog::new(
Some("Waking Host"),
Some(&format!(
"Sent a wake signal to “{}”. Waiting for it to come online…",
req.name
)),
);
waiting.add_responses(&[("cancel", "Cancel")]);
waiting.set_close_response("cancel");
{
let cancel = cancel.clone();
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
}
waiting.present(Some(&app.window));
glib::spawn_future_local(async move {
use std::time::{Duration, Instant};
let events = crate::discovery::browse();
let started = Instant::now();
let budget = Duration::from_secs(90);
let resend = Duration::from_secs(6);
// Fire the first packet now, then re-send on the resend cadence.
crate::wol::wake(&req.mac, req.addr.parse().ok());
let mut last_wake = Instant::now();
loop {
if cancel.get() {
waiting.close();
return;
}
if last_wake.elapsed() >= resend {
crate::wol::wake(&req.mac, req.addr.parse().ok());
last_wake = Instant::now();
}
// Drain resolved adverts; a match (by fingerprint, else addr:port) means the host is up.
while let Ok(ev) = events.try_recv() {
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
continue;
};
let matched = match &req.fp_hex {
Some(fp) => !h.fp_hex.is_empty() && &h.fp_hex == fp,
None => h.addr == req.addr && h.port == req.port,
};
if matched {
waiting.close();
let mut req = req.clone();
// Re-key on a new DHCP lease so this + future connects dial the live address.
if h.addr != req.addr || h.port != req.port {
if let Some(fp) = &req.fp_hex {
trust::rekey_addr(fp, &h.addr, h.port);
}
req.addr = h.addr;
req.port = h.port;
}
initiate_connect(app.clone(), req);
return;
}
}
if started.elapsed() >= budget {
waiting.close();
app.toast(&format!(
"Couldn't reach “{}” — is it powered and on the network?",
req.name
));
return;
}
glib::timeout_future(Duration::from_millis(500)).await;
}
});
}
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines
/// (the Apple TrustCardView format), far easier to compare against the host's log than
/// one 64-char run.
-124
View File
@@ -5,7 +5,6 @@
use super::style::*;
use super::{AppCtx, Screen, Svc, Target};
use crate::discovery::DiscoveredHost;
use crate::session::{self, SessionEvent, SessionParams, Stats};
use crate::trust::{self, KnownHost, KnownHosts, Settings};
use crate::video::DecoderPref;
@@ -314,97 +313,6 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
);
}
/// The Wake-on-LAN "wait until up" flow (mirrors the Apple `HostWaker`): the tapped saved host is
/// offline but has a MAC, so send a magic packet, show a cancelable "Waking…" screen, and POLL mDNS
/// for the host to reappear — re-sending the packet periodically — on a bounded deadline. A cold box
/// takes far longer to POST/boot/re-advertise than a connect attempt will sit, so we can't just
/// fire-and-dial. On reappearance we dial it (re-keying the saved host when it came back on a new
/// IP); on timeout or Cancel we return to the host list.
pub(crate) fn wake_and_connect(
ctx: &Arc<AppCtx>,
target: Target,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
// First packet now; the poll loop re-sends every RESEND_SECS (a single one can be missed, and
// some NICs only wake on a fresh packet after dropping into a deeper sleep state).
crate::wol::wake(&target.mac, target.addr.parse().ok());
// A fresh cancel flag per wake, installed where the "Waking…" screen's Cancel button reads it
// back (the same shared channel as the request-access flow); the poll loop checks the same `Arc`.
let cancel = Arc::new(AtomicBool::new(false));
*ctx.shared.cancel.lock().unwrap() = Some(cancel.clone());
// The busy page reads the host name from the shared target.
*ctx.shared.target.lock().unwrap() = target.clone();
set_status.call(String::new());
set_screen.call(Screen::Waking);
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
std::thread::spawn(move || {
// Generous — a cold boot + service start can be a minute-plus; re-send periodically.
const TIMEOUT_SECS: u64 = 90;
const RESEND_SECS: u64 = 6;
let rx = crate::discovery::browse();
let mut seen: Vec<DiscoveredHost> = Vec::new();
let mut elapsed: u64 = 0;
loop {
// Cancel already returned the UI to the host list — stop re-sending and tear down.
if cancel.load(Ordering::SeqCst) {
return;
}
// Drain freshly-resolved adverts into the accumulator (newest wins per key).
while let Ok(h) = rx.try_recv() {
if let Some(e) = seen.iter_mut().find(|e| e.key == h.key) {
*e = h;
} else {
seen.push(h);
}
}
// Match on the pinned fingerprint first (it survives an IP change), else last address.
let resolved = seen
.iter()
.find(|h| match &target.fp_hex {
Some(fp) if !h.fp_hex.is_empty() => h.fp_hex == *fp,
_ => h.addr == target.addr && h.port == target.port,
})
.map(|h| (h.addr.clone(), h.port));
if let Some((addr, port)) = resolved {
let mut target = target.clone();
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved host so
// the pin stays reachable next time (keyed by fingerprint; addr/port overwritten,
// `paired`/`mac` preserved by `upsert`).
if addr != target.addr || port != target.port {
target.addr = addr;
target.port = port;
if let Some(fp) = target.fp_hex.clone() {
let mut k = KnownHosts::load();
k.upsert(KnownHost {
name: target.name.clone(),
addr: target.addr.clone(),
port: target.port,
fp_hex: fp,
paired: false,
mac: target.mac.clone(),
});
let _ = k.save();
}
}
initiate(&ctx, target, &ss, &st);
return;
}
if elapsed >= TIMEOUT_SECS {
st.call("The host didn't come online.".to_string());
ss.call(Screen::Hosts);
return;
}
std::thread::sleep(Duration::from_secs(1));
elapsed += 1;
if elapsed % RESEND_SECS == 0 {
crate::wol::wake(&target.mac, target.addr.parse().ok());
}
}
});
}
/// The plain "Connecting…" screen shown while the session worker handshakes. No hooks.
pub(crate) fn connecting_page(ctx: &Arc<AppCtx>, status: &str) -> Element {
let target_name = ctx.shared.target.lock().unwrap().name.clone();
@@ -457,35 +365,3 @@ pub(crate) fn request_access_page(
vec![cancel_btn.into()],
)
}
/// The cancelable "Waking…" screen (Wake-on-LAN wait-until-up flow): a spinner + guidance while the
/// poll loop waits for the woken host to reappear on mDNS, plus a Cancel that returns to the host
/// list and trips the shared cancel flag so the poll loop stops re-sending and tears down. No hooks.
pub(crate) fn waking_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
let target_name = ctx.shared.target.lock().unwrap().name.clone();
let headline = if target_name.is_empty() {
"Waking the host\u{2026}".to_string()
} else {
format!("Waking {target_name}\u{2026}")
};
let cancel_btn = {
let (ctx, ss) = (ctx.clone(), set_screen.clone());
button("Cancel")
.icon(Symbol::Cancel)
.on_click(move || {
// Return the UI immediately and trip the flag the poll loop is watching so it stops
// re-sending and exits without touching a screen a later action may already own.
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
c.store(true, Ordering::SeqCst);
}
ss.call(Screen::Hosts);
})
.horizontal_alignment(HorizontalAlignment::Center)
};
busy_page(
&headline,
"Sent a wake signal and waiting for the host to come online \u{2014} this can take up to a \
minute for a sleeping or powered-off machine.",
vec![cancel_btn.into()],
)
}
+5 -7
View File
@@ -2,7 +2,7 @@
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
use super::connect::{initiate, wake_and_connect};
use super::connect::initiate;
use super::speed::SpeedState;
use super::style::*;
use super::{Screen, Svc, Target};
@@ -386,14 +386,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
),
Some(menu),
Some(Box::new(move || {
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
// the network (re-sending periodically) before dialing — a cold box boots far
// slower than a connect will sit. An online host dials straight away.
// Auto-wake an offline saved host before connecting; the connect's own
// retry/timeout gives a woken host time to come up.
if can_wake {
wake_and_connect(&ctx2, target.clone(), &ss, &st);
} else {
initiate(&ctx2, target.clone(), &ss, &st);
crate::wol::wake(&target.mac, target.addr.parse().ok());
}
initiate(&ctx2, target.clone(), &ss, &st)
})),
));
}
+2 -6
View File
@@ -50,9 +50,6 @@ pub(crate) enum Screen {
/// The no-PIN "request access" wait: an identified connect is in flight, parked by the host
/// until the operator approves this device in its console. Cancelable.
RequestAccess,
/// Wake-on-LAN "wait until up": a magic packet was sent to an offline saved host and we're
/// polling mDNS for it to reappear (re-sending periodically) before dialing. Cancelable.
Waking,
Stream,
Settings,
/// Open-source / third-party license notices (reached from Settings).
@@ -381,11 +378,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
set_hover,
},
),
// connecting_page / request_access_page / waking_page / settings_page / licenses_page use
// no hooks (they never touch `cx`), so calling them inline is sound.
// connecting_page / request_access_page / settings_page / licenses_page use no hooks
// (they never touch `cx`), so calling them inline is sound.
Screen::Connecting => connect::connecting_page(ctx, &status),
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
Screen::Waking => connect::waking_page(ctx, &set_screen),
Screen::Settings => settings::settings_page(
ctx,
&set_screen,
-3
View File
@@ -281,9 +281,6 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) ->
// the cursor is free while the session winds down and the UI navigates home.
if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift {
set_captured(st, false);
// Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session
// down immediately instead of holding the keep-alive linger for a reconnect.
st.connector.disconnect_quit();
st.stop.store(true, Ordering::SeqCst);
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
return LRESULT(1);
+1 -1
View File
@@ -16,7 +16,7 @@ use punktfunk_core::session::Session;
use punktfunk_core::transport::loopback_pair;
const TAG_LEN: usize = 16; // AES-GCM authentication tag
const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
const SHARD: usize = 1452; // ~one MTU-sized data shard
fn cfg(role: Role, scheme: FecScheme) -> Config {
Config {
+4 -5
View File
@@ -13,11 +13,10 @@ documentation_style = "c99"
parse_deps = false
[export]
# Internal platform-only FFI — NOT part of the C ABI. cbindgen otherwise sweeps the foreign
# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are
# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs
# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module).
exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
# Internal Apple-only FFI (transport/udp.rs `recvmsg_x` batched recv + its `MsghdrX`) — NOT part of
# the C ABI. cbindgen otherwise sweeps the foreign import and its #[repr(C)] struct into the header,
# where socklen_t/ssize_t/iovec are undefined and the C harness fails to compile.
exclude = ["MsghdrX", "recvmsg_x"]
[export.rename]
"InputEvent" = "PunktfunkInputEvent"
-16
View File
@@ -2432,22 +2432,6 @@ pub unsafe extern "C" fn punktfunk_connection_probe_result(
})
}
/// Signal a **deliberate quit** (a user "stop", not a network drop) before closing: the connection
/// closes with [`QUIT_CLOSE_CODE`] instead of code 0, so the host tears the session down immediately
/// (skips the keep-alive linger) rather than holding it for a reconnect. Call this right before
/// [`punktfunk_connection_close`] on a user-initiated disconnect; a plain close (network drop,
/// backgrounding) leaves the linger intact. NULL is a no-op.
///
/// # Safety
/// `c` was returned by [`punktfunk_connect`] and remains valid (closed via `punktfunk_connection_close`).
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkConnection) {
if let Some(c) = unsafe { c.as_ref() } {
c.inner.disconnect_quit();
}
}
/// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
///
/// # Safety
+3 -92
View File
@@ -123,24 +123,6 @@ pub struct ProbeOutcome {
/// (display freshness over completeness — FEC/keyframes recover).
const FRAME_QUEUE: usize = 16;
/// Backlog latency bound: when completed frames keep arriving further than this behind the host's
/// capture clock (skew-corrected), the pump flushes the receive backlog
/// ([`Session::flush_backlog`]) and requests a keyframe instead of playing that far behind
/// forever. Deliberately generous — an interactive stream is unusable well before 400 ms, but the
/// bound must sit safely above the skew handshake's own error (≈ RTT/2) plus normal delivery
/// jitter so a healthy stream can never trip it.
const FLUSH_LATENCY: Duration = Duration::from_millis(400);
/// How many CONSECUTIVE over-bound frames arm a flush (~0.5 s at 60 fps). A genuine standing queue
/// puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears within a
/// frame or two and never reaches the count.
const FLUSH_AFTER_FRAMES: u32 = 30;
/// Minimum spacing between backlog flushes, so a bottleneck that instantly rebuilds the queue (a
/// link that can't sustain the bitrate at all) degrades into a periodic skip + a logged warning
/// instead of a continuous flush/keyframe storm.
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging
/// embedder drops the newest packet (the audio renderer conceals the gap).
const AUDIO_QUEUE: usize = 64;
@@ -266,9 +248,8 @@ pub struct NativeClient {
/// std channels these worker threads feed; if the producers run at the default QoS, the
/// kernel sees a high-QoS thread parked waiting on a lower-QoS one and the Thread Performance
/// Checker flags a priority inversion. Matching the producers to the consumers' QoS removes
/// the inversion without slowing the Swift side. Android gets a nice-level analogue (see the
/// android arm below); a no-op elsewhere (the Linux client/host don't run a QoS scheduler, and
/// `punktfunk-probe` doesn't care).
/// the inversion without slowing the Swift side. No-op off Apple (the Linux client/host don't
/// run a QoS scheduler, and `punktfunk-probe` doesn't care).
#[cfg(target_vendor = "apple")]
fn pin_thread_user_interactive() {
// SAFETY: sets only the current thread's QoS class — always valid to call.
@@ -276,33 +257,9 @@ fn pin_thread_user_interactive() {
libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0);
}
}
/// Android analogue of the Apple QoS pin: raise the calling thread to nice 8 (the framework's
/// URGENT_DISPLAY band — apps may set negative nice on their own threads). At default nice 0 the
/// EAS scheduler happily parks the data-plane pump (UDP receive + decrypt + FEC — a thread that
/// sleeps between bursts) on a down-clocked little core, and a few ms of scheduling delay during a
/// keyframe burst overflows the socket receive buffer → wire loss the link never saw. 8 keeps the
/// pipeline below the decode thread's 10 (the display path still wins). Best-effort, like Apple's.
#[cfg(target_os = "android")]
fn pin_thread_user_interactive() {
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; a refusal is
// reported via the return value (ignored — a missed boost, not an error on the data path).
unsafe {
let tid = libc::gettid();
let _ = libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8);
}
}
#[cfg(not(any(target_vendor = "apple", target_os = "android")))]
#[cfg(not(target_vendor = "apple"))]
fn pin_thread_user_interactive() {}
/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped
/// capture `pts_ns` after the skew offset is applied — the same latency math the stats HUDs use.
fn now_realtime_ns() -> i128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or(0)
}
/// The calling thread's kernel id, for hot-thread performance hints (the Android client's ADPF
/// session today; the consumer is platform-specific). Linux/Android expose `gettid`; elsewhere
/// there's nothing to hint with, so registration is a no-op.
@@ -625,17 +582,6 @@ impl NativeClient {
self.frames_dropped.load(Ordering::Relaxed)
}
/// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the
/// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the
/// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit)
/// / drop did. Once `true`, every `next_*` plane returns [`PunktfunkError::Closed`] and no more
/// frames will ever arrive. A client watchdog polls this so it can leave a frozen stream and
/// return to the menu (where the user can wake the host) instead of sitting on the last decoded
/// frame forever — the poll-friendly counterpart to reacting to a `Closed` in a plane loop.
pub fn is_session_ended(&self) -> bool {
self.shutdown.load(Ordering::SeqCst)
}
/// Register the calling thread as latency-critical so a later
/// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own
/// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same
@@ -1239,11 +1185,6 @@ async fn worker_main(args: WorkerArgs) {
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
let mut last_report = Instant::now();
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
// Backlog latency bound (see FLUSH_LATENCY): consecutive over-bound frames + the last
// flush, for the cooldown. Armed only when the skew handshake succeeded (offset ≠ 0) —
// without it the host and client clocks aren't comparable and the bound would misfire.
let mut stale_frames: u32 = 0;
let mut last_flush: Option<Instant> = None;
while !pump_shutdown.load(Ordering::SeqCst) {
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
// loop, and (during a speed test) the packet-level receive counters for the throughput
@@ -1278,36 +1219,6 @@ async fn worker_main(args: WorkerArgs) {
if frame.flags & FLAG_PROBE as u32 != 0 {
continue; // speed-test filler, not video — measured via the counters above
}
// Latency bound: a standing receive queue (pump transiently outpaced, a Wi-Fi
// stall, power-save clumping) never drains by itself — the pump consumes at
// exactly the arrival rate, so once behind, the stream stays behind for good
// (observed live: stuck 67 s). When frames keep completing over the bound,
// discard the whole backlog and ask for a keyframe: one visible skip instead of
// a permanently unusable stream. Suspended during a speed test (the probe
// MEASURES a saturated queue; flushing would corrupt its receive counters).
if clock_offset_ns != 0 && !probe_active {
let lat_ns =
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128;
if lat_ns > FLUSH_LATENCY.as_nanos() as i128 {
stale_frames += 1;
} else {
stale_frames = 0;
}
if stale_frames >= FLUSH_AFTER_FRAMES
&& last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
{
stale_frames = 0;
last_flush = Some(Instant::now());
let flushed = session.flush_backlog().unwrap_or(0);
let _ = ctrl_tx.send(CtrlRequest::Keyframe);
tracing::warn!(
behind_ms = lat_ns / 1_000_000,
flushed_datagrams = flushed,
"receive backlog exceeded the latency bound — flushed to live"
);
continue; // this frame is part of the stale past — don't render it
}
}
let _ = frame_tx.try_send(frame);
}
Err(PunktfunkError::NoFrame) => {
-26
View File
@@ -256,19 +256,6 @@ pub const fn max_shard_payload() -> usize {
MAX_DATAGRAM_BYTES - HEADER_LEN - CRYPTO_OVERHEAD
}
/// Largest **even** shard payload whose sealed wire datagram still fits an unfragmented IPv4/UDP
/// packet on a standard 1500-byte MTU: `1500 20 (IPv4) 8 (UDP) HEADER_LEN CRYPTO_OVERHEAD`
/// = 1408. Hosts should default `shard_payload` to this: one byte more and the kernel silently
/// splits EVERY video datagram into two IP fragments (a full frame plus a runt) — either fragment
/// lost = the datagram lost, roughly doubling per-datagram loss on Wi-Fi and eating straight into
/// FEC's recovery margin, plus per-pair kernel reassembly and runt airtime at line rate. (Exactly
/// what the previous hardcoded 1452 did: its MTU math forgot the punktfunk header + crypto ride
/// inside the UDP payload and counted the IP+UDP headers as 8 bytes instead of 28.)
pub const fn mtu1500_shard_payload() -> usize {
let p = 1500 - 20 - 8 - HEADER_LEN - CRYPTO_OVERHEAD;
p - p % 2 // FEC requires even shards
}
/// Everything needed to construct a [`Session`](crate::session::Session).
///
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
@@ -405,19 +392,6 @@ mod tests {
assert!(c.validate().is_err());
}
/// Pin the 1500-MTU wire math: the sealed datagram (header + shard + crypto) at the MTU-safe
/// shard payload must be ≤ 1472 (1500 IPv4 20 UDP 8), and one shard-step (+2) above must
/// not — the regression that shipped as 1452 and IP-fragmented every video datagram.
#[test]
fn mtu1500_shard_payload_never_fragments() {
let p = mtu1500_shard_payload();
assert_eq!(p % 2, 0, "FEC requires even shards");
assert!(p <= max_shard_payload());
let wire = HEADER_LEN + p + CRYPTO_OVERHEAD;
assert!(wire <= 1472, "sealed datagram {wire} B would IP-fragment");
assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1472, "not maximal");
}
#[test]
fn rejects_block_exceeding_scheme_ceiling() {
let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255
+20 -148
View File
@@ -43,29 +43,8 @@ pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN;
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
pub const MAX_DATAGRAM_BYTES: usize = 2048;
/// How far behind the newest frame's capture pts an INCOMPLETE frame may sit before it is
/// declared lost (counted in `frames_dropped`, which triggers the client's recovery-keyframe
/// request). TIME-based, not frame-count-based, so the fuse is the same at every refresh rate: a
/// fixed index window is refresh-relative (4 frames = 66 ms at 60 fps but only 33 ms at 120 fps —
/// inside normal Wi-Fi retry/block-ack reorder timescales, where a delayed-not-lost shard can
/// trail newer frames). Observed live at 120 fps: the too-tight fuse declared merely-late frames
/// dead every few seconds, and each false loss cost a recovery-IDR burst + an inflated loss report
/// (FEC churn) — a self-sustaining latency/bitrate oscillation. 120 ms rides safely above radio
/// retry jitter while still detecting a real loss ~2× faster than the original 16-frame window did
/// at 60 fps.
const LOSS_WINDOW_NS: u64 = 120_000_000;
/// Hard cap on how many frame INDICES behind the newest an incomplete frame may sit, whatever its
/// pts claims — bounds the reassembler's memory against a corrupt/hostile pts (which
/// [`LOSS_WINDOW_NS`] alone would trust) and against pathologically high frame rates. At 120 fps,
/// 120 ms ≈ 14 indices, so 64 leaves ample slack up to ~500 fps.
const HARD_LOSS_WINDOW: u32 = 64;
/// How many frames behind the newest the reassembler remembers emitted/abandoned frame indices
/// (`completed`), so a straggler shard can neither resurrect an abandoned frame nor re-open an
/// emitted one. Must cover at least [`HARD_LOSS_WINDOW`]: stragglers can trickle in later than the
/// loss verdict.
const REORDER_WINDOW: u32 = 64;
/// How many frames behind the newest the reassembler keeps before pruning stragglers.
const REORDER_WINDOW: u32 = 16;
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
#[repr(C)]
@@ -295,10 +274,7 @@ pub struct Reassembler {
/// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to
/// the reorder window alongside `frames`.
completed: HashSet<u32>,
/// The newest frame seen, as `(frame_index, capture pts)` — the loss-window anchor: an
/// incomplete frame is declared lost once it sits [`LOSS_WINDOW_NS`] behind this pts (or
/// [`HARD_LOSS_WINDOW`] indices, whichever trips first).
newest_frame: Option<(u32, u64)>,
newest_frame: Option<u32>,
}
impl Reassembler {
@@ -368,12 +344,12 @@ impl Reassembler {
}
let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec();
self.advance_window(hdr.frame_index, hdr.pts_ns, stats);
self.advance_window(hdr.frame_index, stats);
// Drop shards for frames we've already emitted (e.g. the recovery shards of a
// frame that completed early via the all-originals-present fast path) or that
// have fallen out of the loss window.
if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index, hdr.pts_ns) {
// have fallen out of the reorder window.
if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index) {
drop(stats);
return Ok(None);
}
@@ -485,31 +461,19 @@ impl Reassembler {
Ok(None)
}
/// Track the newest frame, declare incomplete frames that fell out of the loss window
/// ([`LOSS_WINDOW_NS`] behind the newest pts, or [`HARD_LOSS_WINDOW`] indices) lost — counting
/// them dropped, which is what drives the client's recovery-keyframe request — and prune the
/// completed-index memory to [`REORDER_WINDOW`].
fn advance_window(&mut self, frame_index: u32, pts_ns: u64, stats: &StatsCounters) {
let (newest, newest_pts) = match self.newest_frame {
/// Track the newest frame and prune stragglers that fell out of the reorder window
/// (counting them as dropped).
fn advance_window(&mut self, frame_index: u32, stats: &StatsCounters) {
let newest = match self.newest_frame {
// `frame_index` is newer iff it's within the forward half of the index space.
Some((n, p)) if frame_index.wrapping_sub(n) > u32::MAX / 2 => (n, p),
_ => (frame_index, pts_ns),
Some(n) if frame_index.wrapping_sub(n) > u32::MAX / 2 => n,
_ => frame_index,
};
self.newest_frame = Some((newest, newest_pts));
self.newest_frame = Some(newest);
let before = self.frames.len();
let completed = &mut self.completed;
self.frames.retain(|&idx, f| {
let keep = newest.wrapping_sub(idx) <= HARD_LOSS_WINDOW
&& newest_pts.saturating_sub(f.pts_ns) <= LOSS_WINDOW_NS;
if !keep {
// Remember the abandoned index so a straggler shard is dropped (below, and in
// `push`) instead of resurrecting the frame — which would re-allocate its buffers
// and double-count the drop when it aged out again.
completed.insert(idx);
}
keep
});
self.frames
.retain(|&idx, _| newest.wrapping_sub(idx) <= REORDER_WINDOW);
let pruned = before - self.frames.len();
if pruned > 0 {
StatsCounters::add(&stats.frames_dropped, pruned as u64);
@@ -518,29 +482,13 @@ impl Reassembler {
.retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW);
}
/// Drop all in-flight state — every partially-assembled frame and the completed/abandoned
/// index memory — as if the session just started. Used by the client's backlog flush
/// ([`Session::flush_backlog`](crate::session::Session::flush_backlog)): after the socket
/// backlog is discarded wholesale, the partial frames here can never complete (their remaining
/// shards were just thrown away) and the window anchor (`newest_frame`) points into the
/// discarded past.
pub fn reset(&mut self) {
self.frames.clear();
self.completed.clear();
self.newest_frame = None;
}
/// True if this packet's frame lies outside the loss window (behind the newest frame by more
/// than [`LOSS_WINDOW_NS`] of capture time or [`HARD_LOSS_WINDOW`] indices) — its shards
/// arrive too late to be useful, and accepting one would only create a frame buffer the next
/// [`advance_window`] immediately declares lost.
fn is_stale(&self, frame_index: u32, pts_ns: u64) -> bool {
/// True if `frame_index` lies behind the newest frame by more than the reorder
/// window (so its shards arrive too late to be useful).
fn is_stale(&self, frame_index: u32) -> bool {
match self.newest_frame {
Some((n, newest_pts)) => {
Some(n) => {
let behind = n.wrapping_sub(frame_index);
behind <= u32::MAX / 2
&& (behind > HARD_LOSS_WINDOW
|| newest_pts.saturating_sub(pts_ns) > LOSS_WINDOW_NS)
behind > REORDER_WINDOW && behind <= u32::MAX / 2
}
None => false,
}
@@ -637,82 +585,6 @@ mod tests {
assert_eq!(stats.snapshot().packets_dropped, 1);
}
/// The loss window is TIME-based: an incomplete frame survives newer frames arriving within
/// [`LOSS_WINDOW_NS`] of its capture pts (a 33 ms-late shard at 120 fps is late, not lost —
/// the old 4-INDEX window wrongly killed it), is declared lost once the newest pts moves past
/// the window (`frames_dropped`), and a straggler shard can't resurrect it afterwards.
#[test]
fn incomplete_frames_age_out_by_capture_time_not_frame_count() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
const FRAME_NS: u64 = 8_333_333; // 120 fps
// Frame 0: one of its two shards arrives — incomplete.
let mut h = base_header();
h.data_shards = 2;
h.frame_bytes = 32;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Frames 1..=8 complete around it (well past the old 4-index window, inside 120 ms):
// frame 0 must still be alive — no drop counted.
for i in 1..=8u32 {
let mut h = base_header();
h.frame_index = i;
h.pts_ns = i as u64 * FRAME_NS;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_some());
}
assert_eq!(stats.snapshot().frames_dropped, 0);
// Frame 0's second shard arrives 8 frames late (~66 ms at 120 fps) — completes fine.
let mut h = base_header();
h.data_shards = 2;
h.frame_bytes = 32;
h.shard_index = 1;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_some());
// Frame 20: incomplete again; then a frame lands past the 120 ms window → declared lost.
let mut h = base_header();
h.frame_index = 20;
h.pts_ns = 20 * FRAME_NS;
h.data_shards = 2;
h.frame_bytes = 32;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
let mut h = base_header();
h.frame_index = 21;
h.pts_ns = 20 * FRAME_NS + LOSS_WINDOW_NS + 1;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_some());
assert_eq!(stats.snapshot().frames_dropped, 1);
// A straggler shard for the abandoned frame 20 is dropped, never resurrected.
let mut h = base_header();
h.frame_index = 20;
h.pts_ns = 20 * FRAME_NS;
h.data_shards = 2;
h.frame_bytes = 32;
h.shard_index = 1;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(stats.snapshot().frames_dropped, 1, "no double-count");
}
#[test]
fn rejects_wrong_shard_bytes_and_oversized_frame() {
let coder = coder_for(FecScheme::Gf8);
-39
View File
@@ -290,45 +290,6 @@ impl Session {
}
}
/// Client: discard the ENTIRE pending receive backlog — the current recv ring plus everything
/// queued in the kernel socket buffer — and reset the reassembler. Returns how many datagrams
/// were thrown away (counted into `packets_dropped`).
///
/// This is the latency-bound escape hatch: the receive path has no other way to skip ahead.
/// Packets arrive strictly in order, so once a standing queue forms (the pump transiently
/// slower than the wire, a Wi-Fi stall, power-save delivery clumping), the client plays that
/// far behind FOREVER — it consumes at exactly the arrival rate, so the backlog never shrinks
/// (observed live: a stream stuck 67 s behind, socket buffers full end to end). Discarding
/// is memcpy-speed (no decrypt/reassembly/allocation), so this empties even a 32 MB buffer in
/// milliseconds; the caller then requests a keyframe and the stream resumes live. The iteration
/// cap (4096 batches ≈ 128k datagrams ≈ 190 MB) only guards against a line-rate sender
/// outpacing the discard loop indefinitely.
pub fn flush_backlog(&mut self) -> Result<u64> {
if self.config.role != Role::Client {
return Err(PunktfunkError::InvalidArg(
"flush_backlog called on a host session",
));
}
// The undelivered tail of the current ring is backlog too.
let mut flushed = self.recv_count.saturating_sub(self.recv_idx) as u64;
self.recv_count = 0;
self.recv_idx = 0;
if !self.recv_scratch.is_empty() {
for _ in 0..4096 {
let n = self
.transport
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
if n == 0 {
break;
}
flushed += n as u64;
}
}
self.reassembler.reset();
StatsCounters::add(&self.stats.packets_dropped, flushed);
Ok(flushed)
}
/// Client: serialize and send one input event to the host.
pub fn send_input(&mut self, event: &InputEvent) -> Result<()> {
if self.config.role != Role::Client {
+1 -1
View File
@@ -6,7 +6,7 @@ mod qos;
mod udp;
pub use loopback::{loopback_pair, LoopbackTransport};
pub use qos::{grow_socket_buffers, set_dscp_default, set_media_qos, MediaClass};
pub use qos::{grow_socket_buffers, set_media_qos, MediaClass};
/// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected
/// socket (the GameStream video sender) rather than going through [`UdpTransport`].
#[cfg(target_os = "windows")]
+8 -20
View File
@@ -7,13 +7,11 @@
//! [`set_media_qos`] DSCP-tags the latency-sensitive video/audio traffic (+ Linux `SO_PRIORITY`) so a
//! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It
//! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client
//! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer ISPs/routers, and on
//! is **opt-in** (`PUNKTFUNK_DSCP=1`): DSCP can interact badly with some consumer ISPs/routers, and on
//! Windows a plain `IP_TOS` is silently stripped unless a qWAVE policy is active (Apollo uses the
//! qWAVE API there — that port is a follow-up; today this is a no-op on the wire on Windows).
use std::net::UdpSocket;
use std::sync::atomic::{AtomicBool, Ordering};
/// Target kernel socket-buffer size (`SO_SNDBUF`/`SO_RCVBUF`). A high-resolution frame is a burst (a
/// 5120×1440 keyframe is ~130 packets the send thread hands to `sendmmsg` at once); the default UDP
@@ -68,27 +66,17 @@ impl MediaClass {
}
}
/// Runtime default for DSCP marking when `PUNKTFUNK_DSCP` is unset (see [`set_dscp_default`]).
/// Off unless an embedder opts in — on Wi-Fi, access points commonly map DSCP to WMM access
/// categories (a real airtime-priority win), but wired paths rarely honour it and some bleach or
/// reject marked packets, so it never turns on by itself.
static DSCP_DEFAULT: AtomicBool = AtomicBool::new(false);
/// Opt in to (or back out of) DSCP marking for sockets created from now on. Must be called BEFORE
/// connecting — the tag is applied at socket creation. The Android client ties this to its
/// experimental low-latency mode; `PUNKTFUNK_DSCP` still overrides in either direction.
pub fn set_dscp_default(enabled: bool) {
DSCP_DEFAULT.store(enabled, Ordering::Relaxed);
}
/// Whether DSCP/QoS marking is enabled: `PUNKTFUNK_DSCP` when set (`1`/`true`/`on` forces it on,
/// `0`/`false`/`off` forces it off — e.g. to rule QoS out while debugging a flaky AP), else the
/// [`set_dscp_default`] runtime default.
/// Whether DSCP/QoS marking is enabled. Default **on for Android**, **off elsewhere**: on Wi-Fi
/// (where most Android clients live) access points commonly map DSCP to WMM access categories, so
/// tagging the video/audio sockets can win real airtime priority against other traffic on the link;
/// on the wired paths the other clients use it's rarely honoured and some paths bleach or reject
/// marked packets, so it stays opt-in there. `PUNKTFUNK_DSCP` overrides either way — `1`/`true`/`on`
/// forces it on, `0`/`false`/`off` forces it off (e.g. to rule QoS out while debugging a flaky AP).
pub(crate) fn dscp_enabled() -> bool {
match std::env::var("PUNKTFUNK_DSCP").as_deref() {
Ok("1") | Ok("true") | Ok("on") => true,
Ok("0") | Ok("false") | Ok("off") => false,
_ => DSCP_DEFAULT.load(Ordering::Relaxed),
_ => cfg!(target_os = "android"),
}
}
+16 -53
View File
@@ -1,12 +1,10 @@
//! Real UDP datagram transport — native sockets, no async runtime.
//!
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) on Linux AND Android (which is
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally
//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real
//! NIC doesn't drop a line-rate burst. All three layer on this same [`Transport`] seam (scalar
//! fallbacks for loopback and the remaining targets).
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) — the 1 Gbps+ syscall lever
//! (~125k → a few-k syscalls/sec at line rate). The host additionally paces each frame's send
//! across the frame interval (see `punktfunk1.rs::paced_submit`) so a real NIC doesn't drop a line-rate
//! burst. All three layer on this same [`Transport`] seam (scalar fallbacks for loopback/non-Linux).
use super::Transport;
use crate::packet::MAX_DATAGRAM_BYTES;
@@ -59,51 +57,16 @@ fn is_transient_io(e: &std::io::Error) -> bool {
}
}
/// `sendmmsg`/`recvmmsg` + `mmsghdr` for Android, where the `libc` crate binds only the syscall
/// number (`SYS_recvmmsg`) and neither the wrapper functions nor the struct — even though bionic
/// has exported both since API 21 (below our API-28 floor), and Rust's `target_os = "android"` is
/// NOT `"linux"`, so the batched paths below silently excluded Android and the client fell back to
/// one syscall per datagram. The struct layout is stable kernel ABI (`struct mmsghdr` in
/// `linux/socket.h`): a `msghdr` followed by the received byte count.
#[cfg(target_os = "android")]
mod android_mmsg {
#[repr(C)]
#[allow(non_camel_case_types)]
pub struct mmsghdr {
pub msg_hdr: libc::msghdr,
pub msg_len: libc::c_uint,
}
extern "C" {
pub fn sendmmsg(
sockfd: libc::c_int,
msgvec: *mut mmsghdr,
vlen: libc::c_uint,
flags: libc::c_int,
) -> libc::c_int;
pub fn recvmmsg(
sockfd: libc::c_int,
msgvec: *mut mmsghdr,
vlen: libc::c_uint,
flags: libc::c_int,
timeout: *mut libc::timespec,
) -> libc::c_int;
}
}
#[cfg(target_os = "android")]
use android_mmsg::{mmsghdr, recvmmsg, sendmmsg};
#[cfg(target_os = "linux")]
use libc::{mmsghdr, recvmmsg, sendmmsg};
/// Build one `mmsghdr` per `iovec` (each a single-buffer message) for `sendmmsg`/`recvmmsg`. Shared
/// by `send_batch` + `recv_batch` so the raw-pointer scaffolding lives in exactly one place.
///
/// SAFETY (caller's): each returned header holds a raw pointer into `iovs`; the caller MUST keep
/// `iovs` alive and unmoved for as long as the headers are passed to the syscall.
#[cfg(any(target_os = "linux", target_os = "android"))]
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<mmsghdr> {
#[cfg(target_os = "linux")]
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<libc::mmsghdr> {
iovs.iter_mut()
.map(|iov| {
let mut h: mmsghdr = unsafe { std::mem::zeroed() };
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
h.msg_hdr.msg_iov = iov;
h.msg_hdr.msg_iovlen = 1;
h
@@ -612,9 +575,9 @@ impl Transport for UdpTransport {
/// no per-message address. The socket is non-blocking, so a full send buffer surfaces as a
/// short count (or `EAGAIN` with nothing sent); we stop and report what went out rather than
/// block or retry — the data plane is lossy + FEC-protected, and blocking would queue stale
/// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Other targets fall back
/// to the trait's scalar `send` loop (no `sendmmsg`).
#[cfg(any(target_os = "linux", target_os = "android"))]
/// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Non-Linux falls back to
/// the trait's scalar `send` loop (no `sendmmsg`).
#[cfg(target_os = "linux")]
fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
const CHUNK: usize = 64;
@@ -630,7 +593,7 @@ impl Transport for UdpTransport {
})
.collect();
let mut hdrs = mmsghdrs(&mut iovs);
let n = unsafe { sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
let n = unsafe { libc::sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
if n < 0 {
let err = std::io::Error::last_os_error();
// Nothing fit in the send buffer (or a stale ICMP from a connected-socket blip) —
@@ -760,9 +723,9 @@ impl Transport for UdpTransport {
/// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking
/// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and
/// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching
/// `recv`'s oversized-drop. Android uses the local bionic binding (see [`android_mmsg`]).
/// Apple/BSD use the `recv`-loop override below; other non-unix the trait's scalar default.
#[cfg(any(target_os = "linux", target_os = "android"))]
/// `recv`'s oversized-drop. Apple/BSD use the `recv`-loop override below; other non-unix the
/// trait's scalar default.
#[cfg(target_os = "linux")]
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
let fd = self.socket.as_raw_fd();
@@ -780,7 +743,7 @@ impl Transport for UdpTransport {
.collect();
let mut hdrs = mmsghdrs(&mut iovs);
let n = unsafe {
recvmmsg(
libc::recvmmsg(
fd,
hdrs.as_mut_ptr(),
n_bufs as libc::c_uint,
@@ -809,7 +772,7 @@ impl Transport for UdpTransport {
/// batches; our client per-packet-allocated). It is still one syscall per datagram (a future
/// `recvmsg_x` batch would cut that too); `EAGAIN` ends the drain. Oversized datagrams set
/// `lens[i] == buf.len()` and the caller (`poll_frame`) drops them — same contract as `recvmmsg`.
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
#[cfg(all(unix, not(target_os = "linux")))]
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
// and falls through to the always-correct scalar loop below.
-42
View File
@@ -112,48 +112,6 @@ fn lossless_stream_is_exact() {
);
}
/// The client's latency-bound escape hatch: `flush_backlog` must discard every queued datagram
/// (counting them dropped), reset the reassembler so half-assembled frames from the flushed past
/// can't linger, and leave the session healthy — the next submitted frame recovers byte-exact.
#[test]
fn flush_backlog_discards_queue_and_recovers() {
let (host_tp, client_tp) = loopback_pair(0, 0);
let mut host = Session::new(
config(Role::Host, FecScheme::Gf16, false, 0),
Box::new(host_tp),
)
.unwrap();
let mut client = Session::new(
config(Role::Client, FecScheme::Gf16, false, 0),
Box::new(client_tp),
)
.unwrap();
let frames = sample_frames();
// Read one frame first so the client's recv ring exists and may hold an undelivered tail.
host.submit_frame(&frames[0], 0, 0).unwrap();
client.poll_frame().unwrap();
// Queue a multi-frame backlog, then flush it: everything pending is discarded.
for (i, f) in frames.iter().enumerate().skip(1) {
host.submit_frame(f, i as u64 * 1_000_000, 0).unwrap();
}
let flushed = client.flush_backlog().unwrap();
assert!(flushed > 0, "a queued backlog must be discarded");
assert_eq!(client.stats().packets_dropped, flushed);
assert!(
matches!(
client.poll_frame(),
Err(punktfunk_core::PunktfunkError::NoFrame)
),
"nothing pending after a flush"
);
// The stream resumes cleanly: the next frame (the "recovery keyframe") completes byte-exact.
let recovery = vec![0xA5u8; 100_000];
host.submit_frame(&recovery, 99_000_000, 0).unwrap();
let got = client.poll_frame().expect("post-flush frame completes");
assert_eq!(got.data, recovery);
}
#[test]
fn input_round_trips_client_to_host() {
let (host_tp, client_tp) = loopback_pair(0, 0);
+50 -159
View File
@@ -43,12 +43,6 @@ pub struct PortalCapturer {
/// True only while the PipeWire stream is `Streaming`. [`try_latest`](Self::try_latest) reads it
/// to distinguish a static desktop (alive, no new buffers) from a dead source (left `Streaming`).
streaming: Arc<AtomicBool>,
/// Poison flag: the zero-copy GPU import is irrecoverably gone for this stream (the import
/// worker died — e.g. it absorbed the driver fault of a crashing compositor — or tiled imports
/// failed repeatedly, where the CPU fallback would de-pad scrambled tiled bytes). Both
/// [`next_frame`](Capturer::next_frame) and [`try_latest`](Self::try_latest) surface it as an
/// error so the session's capture-loss rebuild runs instead of freezing/corrupting.
broken: Arc<AtomicBool>,
/// When the stream first dropped out of `Streaming` with no new frame; used to grace a transient
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
/// is `Streaming`.
@@ -136,8 +130,6 @@ struct PwHandles {
active: Arc<AtomicBool>,
negotiated: Arc<AtomicBool>,
streaming: Arc<AtomicBool>,
/// See [`PortalCapturer::broken`].
broken: Arc<AtomicBool>,
/// This capture will offer LINEAR-dmabuf-only for the VAAPI passthrough (see
/// [`PortalCapturer::vaapi_dmabuf`]).
vaapi_dmabuf: bool,
@@ -154,7 +146,6 @@ impl PwHandles {
active: self.active,
negotiated: self.negotiated,
streaming: self.streaming,
broken: self.broken,
stall_since: None,
vaapi_dmabuf: self.vaapi_dmabuf,
node_id,
@@ -187,8 +178,6 @@ fn spawn_pipewire(
let negotiated_cb = negotiated.clone();
let streaming = Arc::new(AtomicBool::new(false));
let streaming_cb = streaming.clone();
let broken = Arc::new(AtomicBool::new(false));
let broken_cb = broken.clone();
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
// inner `mod pipewire` shadows the crate name at this scope.
@@ -210,7 +199,6 @@ fn spawn_pipewire(
active_cb,
negotiated_cb,
streaming_cb,
broken_cb,
zerocopy,
preferred,
quit_rx,
@@ -224,7 +212,6 @@ fn spawn_pipewire(
active,
negotiated,
streaming,
broken,
vaapi_dmabuf,
quit: quit_tx,
join,
@@ -233,36 +220,48 @@ fn spawn_pipewire(
impl Capturer for PortalCapturer {
fn next_frame(&mut self) -> Result<CapturedFrame> {
// First frame can lag behind format negotiation; later frames arrive at ~fps. Wait in
// short slices so a GPU-import poison (worker death) fails the capture within ~0.5 s
// instead of sitting out the full first-frame budget.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
if self.broken.load(Ordering::Relaxed) {
return Err(anyhow!(
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
failed repeatedly — rebuilding capture",
// First frame can lag behind format negotiation; later frames arrive at ~fps.
match self.frames.recv_timeout(Duration::from_secs(10)) {
Ok(frame) => Ok(frame),
Err(RecvTimeoutError::Timeout) => {
// Split the two black-screen root causes apart so the operator gets a cause, not
// just a symptom: did the format negotiate (compositor produced no buffers) or
// not (no acceptable format / node never emitted a param)?
if self.negotiated.load(Ordering::Relaxed) {
Err(anyhow!(
"no PipeWire frame within 10s (node {}): format negotiated but no buffers \
arrived — the compositor produced no frames (virtual output idle/unmapped, \
or capture never started)",
self.node_id
));
))
} else if self.vaapi_dmabuf && !crate::zerocopy::vaapi_dmabuf_forced() {
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
// retries on the CPU offer instead of failing this same negotiation forever.
crate::zerocopy::note_vaapi_dmabuf_failed();
Err(anyhow!(
"no PipeWire frame within 10s (node {}): the compositor never accepted \
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
CPU capture path; the pipeline rebuild will renegotiate without dmabuf",
self.node_id
))
} else {
Err(anyhow!(
"no PipeWire frame within 10s (node {}): format negotiation never \
completed — the compositor offered no format this consumer accepts \
(pixel-format/modifier mismatch) or the node never emitted a Format param",
self.node_id
))
}
let slice = Duration::from_millis(500)
.min(deadline.saturating_duration_since(std::time::Instant::now()));
match self.frames.recv_timeout(slice) {
Ok(frame) => return Ok(frame),
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
Err(e) => return self.next_frame_timed_out(e),
}
Err(RecvTimeoutError::Disconnected) => Err(anyhow!(
"PipeWire capture thread ended before a frame (node {})",
self.node_id
)),
}
}
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
if self.broken.load(Ordering::Relaxed) {
return Err(anyhow!(
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
failed repeatedly — rebuilding capture",
self.node_id
));
}
// Drain to the newest queued frame without blocking; `None` means the compositor
// hasn't produced a new frame since last call (static/idle desktop).
let mut latest = None;
@@ -305,50 +304,6 @@ impl Capturer for PortalCapturer {
}
}
impl PortalCapturer {
/// The [`Capturer::next_frame`] budget expired (or the thread ended) — turn it into the
/// diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
fn next_frame_timed_out(&self, err: RecvTimeoutError) -> Result<CapturedFrame> {
match err {
RecvTimeoutError::Timeout => {
// Split the two black-screen root causes apart so the operator gets a cause, not
// just a symptom: did the format negotiate (compositor produced no buffers) or
// not (no acceptable format / node never emitted a param)?
if self.negotiated.load(Ordering::Relaxed) {
Err(anyhow!(
"no PipeWire frame within 10s (node {}): format negotiated but no buffers \
arrived — the compositor produced no frames (virtual output idle/unmapped, \
or capture never started)",
self.node_id
))
} else if self.vaapi_dmabuf && !crate::zerocopy::vaapi_dmabuf_forced() {
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
// retries on the CPU offer instead of failing this same negotiation forever.
crate::zerocopy::note_vaapi_dmabuf_failed();
Err(anyhow!(
"no PipeWire frame within 10s (node {}): the compositor never accepted \
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
CPU capture path; the pipeline rebuild will renegotiate without dmabuf",
self.node_id
))
} else {
Err(anyhow!(
"no PipeWire frame within 10s (node {}): format negotiation never \
completed — the compositor offered no format this consumer accepts \
(pixel-format/modifier mismatch) or the node never emitted a Format param",
self.node_id
))
}
}
RecvTimeoutError::Disconnected => Err(anyhow!(
"PipeWire capture thread ended before a frame (node {})",
self.node_id
)),
}
}
}
impl Drop for PortalCapturer {
fn drop(&mut self) {
// Stop the PipeWire loop and wait for the thread to unwind BEFORE the keepalive (virtual
@@ -593,15 +548,8 @@ mod pipewire {
/// `Paused`/`Unconnected`/`Error` — the source vanished (compositor torn down on a session
/// switch). Read by [`PortalCapturer::try_latest`] to surface a sustained drop as a loss.
streaming: Arc<AtomicBool>,
/// Poison flag (see [`PortalCapturer::broken`]): set here when the GPU import is
/// irrecoverably gone for this stream — the import worker died, or tiled imports failed
/// [`IMPORT_FAIL_POISON`] times in a row.
broken: Arc<AtomicBool>,
/// Consecutive tiled-import failures (reset on success); see [`IMPORT_FAIL_POISON`].
import_fail_streak: u32,
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer,
/// normally via the isolated worker process (`crate::zerocopy::Importer::Remote`).
importer: Option<crate::zerocopy::Importer>,
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer.
importer: Option<crate::zerocopy::EglImporter>,
/// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead
/// of a CUDA import. Set when zero-copy is on, the EGL→CUDA importer is unavailable, and the
/// encoder backend is VAAPI (AMD/Intel).
@@ -613,12 +561,6 @@ mod pipewire {
dbg_log_n: u64,
}
/// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before
/// the stream is poisoned for rebuild. A tiled import failure must NEVER fall through to the
/// CPU mmap path — de-padding tiled bytes as linear produces a scrambled image — so after a
/// short streak of dropped frames the capturer fails loudly and the session renegotiates.
const IMPORT_FAIL_POISON: u32 = 3;
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
/// pipeline must say why without flooding).
fn warn_once(msg: &'static str) {
@@ -872,11 +814,6 @@ mod pipewire {
if !ud.active.load(Ordering::Relaxed) {
return;
}
// Poisoned (GPU import lost): the capturer is already surfacing an error to the encode
// loop; skip per-frame work until the rebuild tears this stream down.
if ud.broken.load(Ordering::Relaxed) {
return;
}
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
@@ -1028,8 +965,6 @@ mod pipewire {
};
match imported {
Ok(devbuf) => {
ud.import_fail_streak = 0;
crate::zerocopy::note_gpu_import_ok();
static ONCE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
@@ -1055,32 +990,12 @@ mod pipewire {
return;
}
Err(e) => {
let dead = importer.dead();
if dead {
crate::zerocopy::note_gpu_import_death();
}
if modifier.is_some() {
// Tiled buffer: the CPU fallback below would mmap TILED bytes
// and de-pad them as linear — a scrambled image, worse than no
// frame. Drop the frame instead; on a dead worker (it absorbed a
// driver fault) or a short failure streak, poison the stream so
// the session's capture-loss rebuild renegotiates cleanly.
ud.import_fail_streak += 1;
if dead || ud.import_fail_streak >= IMPORT_FAIL_POISON {
tracing::error!(error = %format!("{e:#}"), dead,
"tiled GPU import lost — failing this capture for rebuild");
ud.broken.store(true, Ordering::Relaxed);
} else {
// GPU import unavailable for this buffer kind (e.g. the
// driver rejects LINEAR external-memory import). Disable
// the importer and fall through to the CPU mmap path —
// degraded, not dead.
tracing::warn!(error = %format!("{e:#}"),
streak = ud.import_fail_streak,
"tiled dmabuf GPU import failed — frame dropped");
}
return;
}
// LINEAR dmabuf: CPU-mappable, so disable the importer and fall
// through to the CPU mmap path — degraded, not dead.
tracing::warn!(error = %format!("{e:#}"),
"LINEAR dmabuf GPU import failed — falling back to the CPU copy path");
"dmabuf GPU import failed — falling back to the CPU copy path");
gpu_import_broken = true;
}
}
@@ -1223,7 +1138,6 @@ mod pipewire {
active: Arc<AtomicBool>,
negotiated: Arc<AtomicBool>,
streaming: Arc<AtomicBool>,
broken: Arc<AtomicBool>,
zerocopy: bool,
preferred: Option<(u32, u32, u32)>,
quit_rx: pw::channel::Receiver<()>,
@@ -1251,40 +1165,26 @@ mod pipewire {
.context("pw connect (default daemon)")?,
};
// Build the GPU importer up front — normally the ISOLATED worker process
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's
// dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path
// Build the EGL→CUDA importer up front; if it fails, log and fall back to the CPU path
// (we simply won't request dmabuf below). Skipped entirely when the encode backend is
// VAAPI: those frames go to the raw-dmabuf passthrough, and building the importer there
// would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
// succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
// succeed and produce CUDA payloads the VAAPI encoder must reject.
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
let mut importer = if zerocopy && !backend_is_vaapi {
if crate::zerocopy::gpu_import_disabled() {
tracing::warn!(
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
);
None
} else {
match crate::zerocopy::Importer::new_for_capture() {
let importer = if zerocopy && !backend_is_vaapi {
match crate::zerocopy::EglImporter::new() {
Ok(i) => Some(i),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
None
}
}
}
} else {
None
};
// PUNKTFUNK_FORCE_SHM=1 forces the race-free download path (SHM, no dmabuf) — a manual
// escape hatch, mainly for Mutter+NVIDIA: that combo has no implicit dmabuf fence, so
// zero-copy capture can in principle race the compositor's render and show stale frames.
// Zero-copy is the Mutter+NVIDIA default (no unconditional override) since live retesting
// found no visible staleness; set this if you do see flashing/stale content on such a
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
// race).
// PUNKTFUNK_FORCE_SHM=1 forces the race-free download path (SHM, no dmabuf) — required on
// Mutter+NVIDIA where dmabuf capture has no working sync and shows stale frames. KWin/
// gamescope don't need it (they blit into the buffer, so no read-before-render race).
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
// VAAPI zero-copy passthrough: zero-copy on, no EGL→CUDA importer (any non-NVIDIA host), and
// the encoder backend is VAAPI → hand the raw dmabuf to the encoder (it imports + GPU-CSCs).
@@ -1294,7 +1194,7 @@ mod pipewire {
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
// radeonsi/iHD import it and any compositor can allocate it.
let mut modifiers = importer
.as_mut()
.as_ref()
.map(|i| i.supported_modifiers(crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap()))
.unwrap_or_default();
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
@@ -1347,8 +1247,6 @@ mod pipewire {
active,
negotiated,
streaming,
broken,
import_fail_streak: 0,
importer,
vaapi_passthrough,
nv12: crate::zerocopy::nv12_enabled(),
@@ -1402,13 +1300,6 @@ mod pipewire {
}
if ud.info.parse(param).is_ok() {
ud.negotiated.store(true, Ordering::Relaxed);
// A (re)negotiation replaces the buffer pool: every cached per-buffer import
// (stored fds in the worker, the Vulkan bridge's per-fd sources) keys on
// buffers that no longer exist — and a recycled fd number/inode must never
// resolve to a stale import. No-op on the first negotiation (empty caches).
if let Some(imp) = ud.importer.as_mut() {
imp.clear_cache();
}
let sz = ud.info.size();
ud.format = map_format(ud.info.format());
ud.modifier = ud.info.modifier();
+6 -15
View File
@@ -25,12 +25,9 @@
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
//! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`.
//!
//! `PUNKTFUNK_ZEROCOPY` note: this field is a **tri-state override** (`None` = unset). Unset defers to
//! the per-vendor default in `encode/ffmpeg_win.rs::zerocopy_enabled` (AMF on — on-glass validated
//! 2026-07-06; QSV off until validated on Intel glass); an explicit value forces it (`0|false|off|no`
//! = off, anything else = on, so the old presence-style `=1` keeps working). The Linux `zerocopy`
//! module keeps its own *truthy* parser (`1|true|yes|on`) — the two are independent features that
//! share a name; do NOT conflate them.
//! `PUNKTFUNK_ZEROCOPY` note: this field uses **presence** semantics (`var_os(..).is_some()`) to match the
//! Windows `encode/ffmpeg_win.rs` reader. The Linux `zerocopy` module keeps its own *truthy* parser
//! (`1|true|yes|on`) — the two are independent features that share a name; do NOT conflate them.
use std::sync::OnceLock;
@@ -46,9 +43,8 @@ pub struct HostConfig {
pub render_adapter: Option<String>,
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
pub idd_depth: usize,
/// `PUNKTFUNK_ZEROCOPY` — Windows D3D11 zero-copy encode input override. `None` (unset) defers to
/// the per-vendor default (AMF on, QSV off — see module docs and `encode/ffmpeg_win.rs`).
pub zerocopy: Option<bool>,
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
pub zerocopy: bool,
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
pub ten_bit: bool,
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions). Honored only
@@ -88,12 +84,7 @@ impl HostConfig {
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(2),
zerocopy: val("PUNKTFUNK_ZEROCOPY").map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
}),
zerocopy: flag("PUNKTFUNK_ZEROCOPY"),
ten_bit: flag("PUNKTFUNK_10BIT"),
four_four_four: flag("PUNKTFUNK_444"),
perf: flag("PUNKTFUNK_PERF"),
+44 -108
View File
@@ -194,15 +194,6 @@ pub trait Encoder: Send {
}
/// Pull the next encoded AU if one is ready.
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
/// `true` when the encoder was rebuilt: every submitted-but-unpolled frame is forfeited and
/// the next submitted frame starts a fresh stream (IDR). Default `false`: the backend has no
/// in-place rebuild and the caller must treat the stall as fatal instead.
fn reset(&mut self) -> bool {
false
}
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
fn flush(&mut self) -> Result<()>;
@@ -379,9 +370,6 @@ impl Encoder for TrackedEncoder {
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
self.inner.poll()
}
fn reset(&mut self) -> bool {
self.inner.reset()
}
fn flush(&mut self) -> Result<()> {
self.inner.flush()
}
@@ -546,40 +534,17 @@ fn open_video_backend(
)
}
}
WindowsBackend::Amf => {
// AMD: the native AMF SDK encoder, unconditionally (design/native-amf-encoder.md
// Phase 3). The libavcodec AMF fallback and the `PUNKTFUNK_AMF_FFMPEG` hatch were
// removed once the native path was validated — two permanently-maintained AMF
// paths double the driver-matrix burden, and the one kept "for safety" is exactly
// the one with the wedge/latency pathology. No build feature: amfrt64.dll resolves
// at runtime like NVENC's DLL. A missing/ancient runtime fails HERE with the
// "install/update the AMD driver" message `AmfEncoder::open` raises (§6), rather
// than silently degrading — FFmpeg now serves QSV only.
amf::AmfEncoder::open(
codec,
format,
width,
height,
fps,
bitrate_bps,
bit_depth,
chroma,
)
.map(|e| Box::new(e) as Box<dyn Encoder>)
.map_err(|e| {
e.context(
"native AMF encode failed to open (update the AMD driver / amfrt64.dll \
runtime)",
)
})
}
WindowsBackend::Qsv => {
// Intel QSV via libavcodec (stays on FFmpeg — design/native-amf-encoder.md §2:
// async_depth=1 + low_power VDEnc is already near the hardware latency floor).
backend @ (WindowsBackend::Amf | WindowsBackend::Qsv) => {
// AMD AMF / Intel QSV via libavcodec (the Windows analogue of the Linux VAAPI path).
#[cfg(feature = "amf-qsv")]
{
let vendor = if matches!(backend, WindowsBackend::Amf) {
ffmpeg_win::WinVendor::Amf
} else {
ffmpeg_win::WinVendor::Qsv
};
ffmpeg_win::FfmpegWinEncoder::open(
ffmpeg_win::WinVendor::Qsv,
vendor,
codec,
format,
width,
@@ -593,10 +558,11 @@ fn open_video_backend(
}
#[cfg(not(feature = "amf-qsv"))]
{
let _ = backend;
anyhow::bail!(
"Intel (QSV) encode requested/detected but this host was built without \
it rebuild with `--features amf-qsv` (needs ffmpeg-next + a FFMPEG_DIR \
with the QSV encoders at build time)"
"AMD/Intel (AMF/QSV) encode requested/detected but this host was built \
without it rebuild with `--features amf-qsv` (needs ffmpeg-next + a \
FFMPEG_DIR with the AMF/QSV encoders at build time)"
)
}
}
@@ -807,13 +773,14 @@ pub fn can_encode_444(codec: Codec) -> bool {
false
}
}
// AMD: native AMF never encodes 4:4:4 — VCN hardware limit, permanent, no probe
// needed (design/native-amf-encoder.md §3.5, Phase 3).
WindowsBackend::Amf => false,
WindowsBackend::Qsv => {
WindowsBackend::Amf | WindowsBackend::Qsv => {
#[cfg(feature = "amf-qsv")]
{
ffmpeg_win::probe_can_encode_444(ffmpeg_win::WinVendor::Qsv, codec)
let vendor = match windows_resolved_backend() {
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
_ => ffmpeg_win::WinVendor::Amf,
};
ffmpeg_win::probe_can_encode_444(vendor, codec)
}
#[cfg(not(feature = "amf-qsv"))]
{
@@ -880,18 +847,16 @@ pub(crate) fn windows_resolved_backend() -> WindowsBackend {
}
}
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV still needs
/// the `amf-qsv` (libavcodec) build. Formerly `windows_backend_is_ffmpeg`, renamed when the
/// native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2).
/// True if the active Windows backend is the libavcodec AMF/QSV path (so the codec advertisement
/// consults a real GPU probe rather than the NVENC static superset). Always false when the
/// `amf-qsv` feature is off — there's then no ffmpeg backend to probe.
#[cfg(target_os = "windows")]
pub fn windows_backend_is_probed() -> bool {
match windows_resolved_backend() {
WindowsBackend::Amf => true,
WindowsBackend::Qsv => cfg!(feature = "amf-qsv"),
WindowsBackend::Nvenc | WindowsBackend::Software => false,
}
pub fn windows_backend_is_ffmpeg() -> bool {
cfg!(feature = "amf-qsv")
&& matches!(
windows_resolved_backend(),
WindowsBackend::Amf | WindowsBackend::Qsv
)
}
/// Detect the encode-GPU vendor from the **selected render adapter** ([`crate::gpu::selected_gpu`]:
@@ -920,55 +885,32 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
})
}
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached **per (backend,
/// selected GPU)** — a web-console preference change re-probes on the newly selected adapter
/// instead of serving the old GPU's answer for the process lifetime). Mirrors
/// [`vaapi_codec_support`]; called only when [`windows_backend_is_probed`] is true. AV1 is narrow
/// (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
///
/// Mirrors the session dispatch (design/native-amf-encoder.md Phase 3): **AMD advertises from the
/// native AMF factory probe alone** (`amf::probe_can_encode`, on the selected adapter — the same
/// path the session opens, so the advertisement can never claim a codec the session can't emit);
/// **Intel/QSV uses the libavcodec probe** (all-`false` without the `amf-qsv` feature, matching a
/// build that cannot open QSV at all).
#[cfg(target_os = "windows")]
/// Probe the active Windows AMF/QSV backend for its encodable codecs (opens a tiny encoder per
/// codec; cached **per (backend, selected GPU)** — a web-console preference change re-probes on the
/// newly selected adapter instead of serving the old GPU's answer for the process lifetime).
/// Mirrors [`vaapi_codec_support`]; called only when [`windows_backend_is_ffmpeg`] is true. AV1 is
/// narrow (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
pub fn windows_codec_support() -> CodecSupport {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
let backend = windows_resolved_backend();
let key = format!("{backend:?}:{}", crate::gpu::selection_key());
let vendor = match windows_resolved_backend() {
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
_ => ffmpeg_win::WinVendor::Amf,
};
let key = format!("{vendor:?}:{}", crate::gpu::selection_key());
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(c) = cache.lock().unwrap().get(&key) {
return *c;
}
let probe_one = |codec: Codec| -> bool {
match backend {
// AMD: the native factory probe is authoritative — it opens exactly the component the
// session will, so the advertisement matches what the encoder can emit by construction.
WindowsBackend::Amf => amf::probe_can_encode(codec),
WindowsBackend::Qsv => {
#[cfg(feature = "amf-qsv")]
{
ffmpeg_win::probe_can_encode(ffmpeg_win::WinVendor::Qsv, codec)
}
#[cfg(not(feature = "amf-qsv"))]
{
false
}
}
// Callers gate on `windows_backend_is_probed` — defensively answer "nothing probed"
// (the advertisement then falls back to the static superset).
WindowsBackend::Nvenc | WindowsBackend::Software => false,
}
};
let caps = CodecSupport {
h264: probe_one(Codec::H264),
h265: probe_one(Codec::H265),
av1: probe_one(Codec::Av1),
h264: ffmpeg_win::probe_can_encode(vendor, Codec::H264),
h265: ffmpeg_win::probe_can_encode(vendor, Codec::H265),
av1: ffmpeg_win::probe_can_encode(vendor, Codec::Av1),
};
tracing::info!(
?backend,
backend = ?vendor,
h264 = caps.h264,
h265 = caps.h265,
av1 = caps.av1,
@@ -979,14 +921,8 @@ pub fn windows_codec_support() -> CodecSupport {
caps
}
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, native AMF, AMF/QSV
// ffmpeg, software) and `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the
// `crate::encode::*` module names flat.
// Native AMF (direct SDK, design/native-amf-encoder.md): compiled unconditionally on Windows —
// no build feature, the driver-installed amfrt64.dll resolves at runtime like NVENC's DLL.
#[cfg(target_os = "windows")]
#[path = "encode/windows/amf.rs"]
mod amf;
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
#[path = "encode/windows/ffmpeg_win.rs"]
mod ffmpeg_win;
File diff suppressed because it is too large Load Diff
@@ -1,37 +1,28 @@
//! Intel **QSV** (and, retained-but-no-longer-dispatched, AMD **AMF**) hardware encode on Windows
//! via `ffmpeg-next` — the Windows analogue of the Linux [`super::vaapi`] backend (one libavcodec
//! backend per vendor, selected by encoder name: `*_qsv` / `*_amf`). Sibling of the direct-SDK
//! [`super::nvenc`] path behind the shared [`Encoder`] trait.
//!
//! **Dispatch (design/native-amf-encoder.md Phase 3):** [`super::open_video`] routes AMD to the
//! direct-SDK [`super::amf`] encoder, not this module — the libavcodec AMF wrapper's ~2-frame
//! output hold and its silent-wedge failure mode are exactly why the native path exists. So in
//! production this file serves **QSV only**. The `WinVendor::Amf` machinery is kept (not deleted)
//! because it is the comparator in the native-vs-libavcodec latency A/B (`amf::tests::
//! amf_latency_ab_bench`), and excising it would churn the shared, Intel-unvalidated QSV code for
//! no production benefit. Treat every `WinVendor::Amf` arm below as benchmark-only.
//! AMD **AMF** and Intel **QSV** hardware encode on Windows via `ffmpeg-next` — the Windows
//! analogue of the Linux [`super::vaapi`] backend (one libavcodec backend per vendor, selected by
//! encoder name: `*_amf` / `*_qsv`). This is the sibling of the direct-SDK [`super::nvenc`] path
//! behind the shared [`Encoder`] trait, selected in [`super::open_video`] (NVIDIA → NVENC,
//! AMD → AMF, Intel → QSV).
//!
//! The capturer hands a `FramePayload::D3d11` texture (NV12/P010 from the D3D11 video processor, or
//! BGRA/Rgb10a2 as a fallback) on the capturer's own `ID3D11Device`. Two input paths, chosen lazily
//! from the first frame and the `PUNKTFUNK_ZEROCOPY` knob:
//!
//! * **System-memory** ([`SystemInner`]): read the captured D3D11 surface back to a CPU
//! * **System-memory** ([`SystemInner`], the default): read the captured D3D11 surface back to a CPU
//! NV12/P010 [`AVFrame`] (a same-format `CopyResource` → staging → `Map`, plus a `swscale` step for
//! the BGRA fallback) and `avcodec_send_frame` it. AMF/QSV upload it internally. One
//! GPU→CPU→GPU round-trip per frame — the robust path, the QSV default, and the automatic
//! fallback when the zero-copy setup fails (it is the analogue of the VAAPI "CPU input" fallback).
//! * **Zero-copy D3D11** ([`ZeroCopyInner`], the AMF default; see [`zerocopy_enabled`]): wrap the
//! capturer's `ID3D11Device` as an `AV_HWDEVICE_TYPE_D3D11VA` hwdevice (shared, *not* a second
//! device — the capture textures are not shared-handle, so a different device couldn't read them),
//! keep an FFmpeg D3D11 frames pool, `CopySubresourceRegion` the captured texture into a pooled
//! array slice (a GPU-local copy, like NVENC's CUDA path), then feed AMF `AV_PIX_FMT_D3D11`
//! directly, or map the D3D11 frame to a derived QSV surface for QSV. If the hw setup fails to
//! open, this falls back to the system-memory path for the session.
//! GPU→CPU→GPU round-trip per frame — the robust path, and the only one that can be brought up
//! without on-glass validation (it is the analogue of the VAAPI "CPU input" fallback).
//! * **Zero-copy D3D11** ([`ZeroCopyInner`], `PUNKTFUNK_ZEROCOPY=1`): wrap the capturer's
//! `ID3D11Device` as an `AV_HWDEVICE_TYPE_D3D11VA` hwdevice (shared, *not* a second device — the
//! capture textures are not shared-handle, so a different device couldn't read them), keep an
//! FFmpeg D3D11 frames pool, `CopySubresourceRegion` the captured texture into a pooled array
//! slice (a GPU-local copy, like NVENC's CUDA path), then feed AMF `AV_PIX_FMT_D3D11` directly,
//! or map the D3D11 frame to a derived QSV surface for QSV. If the hw setup fails to open, this
//! falls back to the system-memory path for the session.
//!
//! **Status:** AMF on-glass validated 2026-07-06 (Ryzen 7000 iGPU, 1080p120 HDR P010, both input
//! paths; zero-copy cut `submit_us` p50 2.8 ms → 0.26 ms) — zero-copy is the AMF default. QSV is
//! still not on-glass validated (no Intel Windows box in the lab), so its zero-copy path stays
//! opt-in via `PUNKTFUNK_ZEROCOPY=1`.
//! **Status: compiles in CI; not yet on-glass validated** (no AMD/Intel Windows box in the lab as of
//! 2026-06-22). The system path is the conservative default; zero-copy is opt-in until validated.
//!
//! Raw FFI: `ffmpeg-next` has no hwcontext wrappers for D3D11VA, so the hwdevice/hwframes calls go
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
@@ -117,16 +108,10 @@ impl WinVendor {
}
}
/// Is the zero-copy D3D11 path enabled for this vendor? An explicit `PUNKTFUNK_ZEROCOPY`
/// (`0|false|off|no` = off, anything else = on) overrides; unset defers to the per-vendor default:
/// **on for AMF** — on-glass validated 2026-07-06 (Ryzen iGPU, 1080p120 HDR P010: `submit_us` p50
/// 2.8 ms → 0.26 ms vs readback) — and **off for QSV** until validated on Intel glass (the
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
fn zerocopy_enabled(vendor: WinVendor) -> bool {
crate::config::config()
.zerocopy
.unwrap_or(matches!(vendor, WinVendor::Amf))
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated;
/// the default is the robust system-memory readback path.
fn zerocopy_enabled() -> bool {
crate::config::config().zerocopy
}
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
@@ -786,9 +771,9 @@ impl Drop for SystemInner {
}
// ---------------------------------------------------------------------------------------------
// Zero-copy D3D11 path (the AMF default; QSV opt-in — see `zerocopy_enabled`): share the capture
// device, pool D3D11 frames, copy the captured texture into a pooled slice, feed AMF directly /
// map to QSV. Falls back to the system path if the hw setup fails to open.
// Zero-copy D3D11 path (PUNKTFUNK_ZEROCOPY=1): share the capture device, pool D3D11 frames, copy
// the captured texture into a pooled slice, feed AMF directly / map to QSV. Falls back to the
// system path if the hw setup fails to open. Untested on glass — opt-in only for now.
// ---------------------------------------------------------------------------------------------
struct D3d11Hw {
@@ -1214,7 +1199,7 @@ impl FfmpegWinEncoder {
}
self.inner = None;
self.bound_device = dev_raw;
let inner = if zerocopy_enabled(self.vendor) {
let inner = if zerocopy_enabled() {
match ZeroCopyInner::open(
self.vendor,
self.codec,
@@ -1322,18 +1307,6 @@ impl Encoder for FfmpegWinEncoder {
self.force_kf = true;
}
/// Encode-stall recovery: drop the wedged libavcodec encoder (its `Drop` releases the AMF/QSV
/// runtime state) and let the next `submit` rebuild it lazily on the current device, exactly
/// like first-frame bring-up. The owed AUs are forfeited (`in_flight` zeroed) and the rebuilt
/// encoder's first frame is forced IDR so the client resyncs immediately.
fn reset(&mut self) -> bool {
self.inner = None;
self.bound_device = 0;
self.in_flight = 0;
self.force_kf = true;
true
}
/// Poll for the next finished AU (single non-blocking `receive_packet`).
///
/// libavcodec's `hevc_amf`/`av1_amf` wrapper holds ~2 frames before releasing the oldest
@@ -77,10 +77,9 @@ fn base_codec_mode_support() -> u32 {
}
}
// Windows AMD/Intel (AMF/QSV): advertise only what the GPU actually encodes (AV1 is narrow, an
// old iGPU might lack HEVC). AMF probes natively (no build feature needed); QSV needs the
// libavcodec build. NVENC and the GPU-less software path keep the static superset.
#[cfg(target_os = "windows")]
if crate::encode::windows_backend_is_probed() {
// old iGPU might lack HEVC). NVENC and the GPU-less software path keep the static superset.
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
if crate::encode::windows_backend_is_ffmpeg() {
if let Some(m) = probed_mask(crate::encode::windows_codec_support()) {
return m;
}
@@ -92,7 +91,7 @@ fn base_codec_mode_support() -> u32 {
/// or `None` if the probe found nothing — meaning the GPU wasn't usable at probe time (GPU-less CI,
/// a misconfigured/wrong-vendor host), NOT that it encodes zero codecs; the caller then advertises
/// the static superset (pre-probe behaviour) rather than claiming nothing.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
fn probed_mask(caps: crate::encode::CodecSupport) -> Option<u32> {
use super::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC};
let mut m = 0;
@@ -1,609 +0,0 @@
//! Host side of the isolated zero-copy GPU import (design:
//! [`design/zerocopy-worker-isolation.md`]): spawns the `zerocopy-worker` subprocess, mirrors the
//! [`super::egl::EglImporter`] entry points over the [`super::proto`] socket, and materializes
//! the worker's pooled CUDA buffers in this process via CUDA IPC (each buffer's handles are
//! opened exactly once and reused as the pool recycles). A worker death — the whole point of the
//! isolation — surfaces as an `Err` with [`RemoteImporter::dead`] set, never as a host fault.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, CUdeviceptr, DeviceBuffer, CU_IPC_HANDLE_SIZE};
use super::egl::DmabufPlane;
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
use anyhow::{bail, Context, Result};
use std::collections::{HashMap, HashSet};
use std::io;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
use std::path::Path;
use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
/// Per-request budget. An import is a few ms of GPU work; if the worker can't answer in this
/// window it is wedged (GPU fault in progress) and gets treated as dead.
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
/// State shared with in-flight frames: the socket (their release messages) and the CUDA IPC
/// mappings (their device pointers). Lives until the LAST in-flight [`DeviceBuffer`] drops, so a
/// mapping is never closed under a frame the encoder still reads — and only then does the socket
/// close, which is what tells an idle worker to exit.
struct Shared {
sock: OwnedFd,
mappings: Mutex<HashMap<u32, Mapping>>,
dead: AtomicBool,
}
/// One pooled worker buffer, opened in this process.
#[derive(Clone, Copy)]
struct Mapping {
y: CUdeviceptr,
y_pitch: usize,
uv: Option<(CUdeviceptr, usize)>,
width: u32,
height: u32,
}
impl Drop for Shared {
fn drop(&mut self) {
// Last reference gone — no DeviceBuffer can still point into these mappings.
for (_, m) in self.mappings.lock().unwrap().drain() {
cuda::ipc_close(m.y);
if let Some((uv, _)) = m.uv {
cuda::ipc_close(uv);
}
}
}
}
/// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket
/// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so
/// workers don't linger as zombies for more than one capture generation.
static REAPER: Mutex<Vec<Child>> = Mutex::new(Vec::new());
fn sweep_reaper() {
let mut list = REAPER.lock().unwrap();
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
}
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
/// [`super::egl::EglImporter`] surface the capture thread uses.
pub struct RemoteImporter {
shared: Arc<Shared>,
child: Option<Child>,
/// Reused receive scratch buffer (all replies are read by the single capture thread).
rbuf: Vec<u8>,
/// Dmabuf keys (`st_ino`) whose fd the worker already holds — the fd is passed only once.
sent_keys: HashSet<u64>,
}
impl RemoteImporter {
/// Spawn the worker from this host binary and complete the readiness handshake. An `Err`
/// here means "no isolated zero-copy available" — callers fall back to the CPU path, exactly
/// like an in-process `EglImporter::new()` failure.
pub fn spawn() -> Result<RemoteImporter> {
let exe = std::env::current_exe().context("resolve /proc/self/exe for the worker")?;
Self::spawn_exe(&exe)
}
/// [`Self::spawn`] with an explicit executable (separated for tests).
fn spawn_exe(exe: &Path) -> Result<RemoteImporter> {
sweep_reaper();
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
let mut cmd = Command::new(exe);
cmd.arg("zerocopy-worker").arg("--fd").arg("3");
let raw = worker_end.as_raw_fd();
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
// allowed — `dup2` and `fcntl` both are, and the closure captures only the `Copy` int
// `raw` (no allocation, no locks). `dup2(raw, 3)` installs the socket at the fd number
// the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(move || {
if raw == 3 {
let flags = libc::fcntl(3, libc::F_GETFD);
if flags < 0 || libc::fcntl(3, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 {
return Err(io::Error::last_os_error());
}
} else if libc::dup2(raw, 3) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let child = cmd.spawn().context("spawn zerocopy-worker")?;
drop(worker_end); // the child holds its own copy now
Self::from_socket(host_end, Some(child))
}
/// Complete the handshake on an already-connected socket (the unit tests drive this against
/// a mock server thread instead of a real subprocess).
fn from_socket(sock: OwnedFd, child: Option<Child>) -> Result<RemoteImporter> {
let mut importer = RemoteImporter {
shared: Arc::new(Shared {
sock,
mappings: Mutex::new(HashMap::new()),
dead: AtomicBool::new(false),
}),
child,
rbuf: Vec::new(),
sent_keys: HashSet::new(),
};
proto::set_recv_timeout(importer.shared.sock.as_fd(), Some(HANDSHAKE_TIMEOUT))?;
let ready = proto::recv::<Reply>(importer.shared.sock.as_fd(), &mut importer.rbuf);
proto::set_recv_timeout(importer.shared.sock.as_fd(), Some(REPLY_TIMEOUT))?;
match ready {
Ok((Reply::Ready { version }, _)) if version == proto::PROTO_VERSION => {
tracing::info!(
pid = importer.child.as_ref().map(|c| c.id()),
"zero-copy GPU import isolated in a worker process"
);
Ok(importer)
}
Ok((Reply::Ready { version }, _)) => {
importer.mark_dead();
bail!(
"zerocopy worker protocol mismatch (worker v{version}, host v{})",
proto::PROTO_VERSION
)
}
Ok((Reply::InitErr { message }, _)) => {
// The worker exits by itself after reporting; not a death, just "no GPU here".
bail!("zerocopy worker init failed: {message}")
}
Ok((other, _)) => {
importer.mark_dead();
bail!("unexpected zerocopy worker handshake: {other:?}")
}
Err(e) => {
importer.mark_dead();
Err(e).context("zerocopy worker handshake (died on startup?)")
}
}
}
/// True once any exchange failed at the transport level — the worker is gone (or wedged) and
/// every further call fails fast. The capture layer poisons its stream on this.
pub fn dead(&self) -> bool {
self.shared.dead.load(Ordering::Relaxed)
}
fn mark_dead(&self) {
self.shared.dead.store(true, Ordering::Relaxed);
}
/// Mirror of [`super::egl::EglImporter::supported_modifiers`] (worker round-trip; empty on
/// any failure, which makes the capture fall back like an importless negotiation).
pub fn supported_modifiers(&mut self, fourcc: u32) -> Vec<u64> {
if self.dead() {
return Vec::new();
}
if let Err(e) = proto::send(
self.shared.sock.as_fd(),
&Request::Modifiers { fourcc },
None,
) {
tracing::warn!(error = %e, "zerocopy worker modifier query failed");
self.mark_dead();
return Vec::new();
}
match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
Ok((Reply::Modifiers { modifiers }, _)) => modifiers,
Ok((other, _)) => {
tracing::warn!(?other, "unexpected zerocopy worker reply to Modifiers");
self.mark_dead();
Vec::new()
}
Err(e) => {
tracing::warn!(error = %e, "zerocopy worker modifier reply failed");
self.mark_dead();
Vec::new()
}
}
}
/// Mirror of [`super::egl::EglImporter::import`] (tiled dmabuf → BGRx CUDA buffer).
pub fn import(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
fourcc: u32,
modifier: Option<u64>,
) -> Result<DeviceBuffer> {
self.import_impl(plane, ImportKind::Tiled, width, height, fourcc, modifier)
}
/// Mirror of [`super::egl::EglImporter::import_nv12`].
pub fn import_nv12(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
fourcc: u32,
modifier: Option<u64>,
) -> Result<DeviceBuffer> {
self.import_impl(
plane,
ImportKind::TiledNv12,
width,
height,
fourcc,
modifier,
)
}
/// Mirror of [`super::egl::EglImporter::import_linear`] (LINEAR dmabuf → Vulkan bridge).
pub fn import_linear(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
) -> Result<DeviceBuffer> {
self.import_impl(plane, ImportKind::Linear, width, height, 0, None)
}
fn import_impl(
&mut self,
plane: &DmabufPlane,
kind: ImportKind,
width: u32,
height: u32,
fourcc: u32,
modifier: Option<u64>,
) -> Result<DeviceBuffer> {
if self.dead() {
bail!("zerocopy worker is dead");
}
let key = dmabuf_key(plane.fd)?;
// One retry: a `NeedFd` reply (the worker's fd cache evicted this key) clears our
// "already sent" note so the second attempt carries the fd again.
let mut attempts = 0;
let reply = loop {
attempts += 1;
let has_fd = self.sent_keys.insert(key);
// SAFETY: `plane.fd` is the dmabuf fd of the PipeWire buffer the capture thread still
// holds for this callback (`consume_frame`'s contract), so it is open and stays open
// for this synchronous call; the `BorrowedFd` never outlives it (used only for the
// `send`).
let pass = has_fd.then(|| unsafe { BorrowedFd::borrow_raw(plane.fd) });
let req = Request::Import {
key,
kind,
width,
height,
fourcc,
modifier,
offset: plane.offset,
stride: plane.stride,
has_fd,
};
if let Err(e) = proto::send(self.shared.sock.as_fd(), &req, pass) {
self.mark_dead();
return Err(e).context("zerocopy worker died (send)");
}
let reply = match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
Ok((reply, _)) => reply,
Err(e) => {
self.mark_dead();
return Err(e).context("zerocopy worker died (no reply)");
}
};
match reply {
Reply::NeedFd if attempts == 1 => {
self.sent_keys.remove(&key);
continue;
}
Reply::NeedFd => {
self.mark_dead();
bail!("zerocopy worker still lacks the fd after a resend (desync)");
}
other => break other,
}
};
match reply {
Reply::Frame { id, desc } => {
if let Some(desc) = desc {
let mapping = open_mapping(&desc).with_context(|| {
// An unopenable mapping poisons every future frame in this buffer —
// treat it as a dead worker so the capture rebuilds cleanly.
self.mark_dead();
format!("open CUDA IPC mapping for worker buffer {id}")
})?;
self.shared.mappings.lock().unwrap().insert(id, mapping);
}
let m = self
.shared
.mappings
.lock()
.unwrap()
.get(&id)
.copied()
.ok_or_else(|| {
self.mark_dead();
anyhow::anyhow!("worker delivered unknown buffer id {id} (desync)")
})?;
let shared = self.shared.clone();
Ok(DeviceBuffer::remote(
m.y,
m.y_pitch,
m.width,
m.height,
m.uv,
Box::new(move || {
// Fire-and-forget recycle; a dead worker just means EPIPE, ignored. The
// captured `shared` Arc is what keeps the mapping + socket alive until
// the last frame drops.
let _ = proto::send(shared.sock.as_fd(), &Request::Release { id }, None);
}),
))
}
Reply::Err { message } => bail!("zerocopy worker import failed: {message}"),
other => {
self.mark_dead();
bail!("unexpected zerocopy worker reply: {other:?}")
}
}
}
/// The PipeWire stream renegotiated — reset both sides' per-buffer caches.
pub fn clear_cache(&mut self) {
self.sent_keys.clear();
if !self.dead() {
if let Err(e) = proto::send(self.shared.sock.as_fd(), &Request::ClearCache, None) {
tracing::warn!(error = %e, "zerocopy worker ClearCache failed");
self.mark_dead();
}
}
}
}
impl Drop for RemoteImporter {
fn drop(&mut self) {
// The worker exits on socket EOF, which happens when the last `Shared` reference (this
// importer, or the final in-flight frame on the encode side) drops. Reap what's already
// gone; park the rest for the next sweep.
if let Some(mut child) = self.child.take() {
if !matches!(child.try_wait(), Ok(Some(_))) {
REAPER.lock().unwrap().push(child);
}
}
sweep_reaper();
}
}
/// Identity of the dma-buf behind `fd`, stable across frames and across `SCM_RIGHTS` re-numbering:
/// every dma-buf gets a unique inode on the kernel's dmabuf pseudo-fs for its lifetime. Used as
/// the worker's fd-cache key so the fd itself is only passed once.
fn dmabuf_key(fd: i32) -> Result<u64> {
// SAFETY: `libc::stat` is plain-old-data for which all-zero is a valid value, so
// `mem::zeroed()` is a sound initializer. `fd` is the caller's live dmabuf fd; `fstat` writes
// into `&mut st`, a live, correctly-sized stack struct that outlives the synchronous call,
// and `st_ino` is read only after the return value is checked.
unsafe {
let mut st: libc::stat = std::mem::zeroed();
if libc::fstat(fd, &mut st) != 0 {
bail!("fstat(dmabuf fd): {}", io::Error::last_os_error());
}
Ok(st.st_ino)
}
}
/// Open a worker buffer's CUDA IPC handles in this process.
fn open_mapping(desc: &BufferDesc) -> Result<Mapping> {
cuda::make_current()?;
let y_handle: [u8; CU_IPC_HANDLE_SIZE] = desc
.y_handle
.as_slice()
.try_into()
.context("worker sent a malformed Y IPC handle")?;
let y = cuda::ipc_open(&y_handle).context("open Y plane IPC handle")?;
let uv = match &desc.uv {
Some((handle, pitch)) => {
let handle: [u8; CU_IPC_HANDLE_SIZE] = handle
.as_slice()
.try_into()
.context("worker sent a malformed UV IPC handle")?;
match cuda::ipc_open(&handle) {
Ok(ptr) => Some((ptr, *pitch)),
Err(e) => {
// Don't leak the Y mapping on a half-open failure.
cuda::ipc_close(y);
return Err(e).context("open UV plane IPC handle");
}
}
}
None => None,
};
Ok(Mapping {
y,
y_pitch: desc.y_pitch,
uv,
width: desc.width,
height: desc.height,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
fn handshake_server(reply: Reply) -> OwnedFd {
let (host, worker) = proto::socketpair_seqpacket().unwrap();
proto::send(worker.as_fd(), &reply, None).unwrap();
// Keep the worker end alive alongside the host end for the test's duration by leaking it
// into the reply thread below? Not needed: the handshake reply is already queued in the
// socket buffer, so the worker end may drop — recv still delivers queued data first.
drop(worker);
host
}
#[test]
fn handshake_ready_and_version_gate() {
let host = handshake_server(Reply::Ready {
version: proto::PROTO_VERSION,
});
let imp = RemoteImporter::from_socket(host, None).unwrap();
assert!(!imp.dead());
let host = handshake_server(Reply::Ready { version: 999 });
assert!(RemoteImporter::from_socket(host, None).is_err());
}
#[test]
fn handshake_init_err() {
let host = handshake_server(Reply::InitErr {
message: "no GPU".into(),
});
let Err(err) = RemoteImporter::from_socket(host, None) else {
panic!("InitErr handshake must fail")
};
assert!(format!("{err:#}").contains("no GPU"), "{err:#}");
}
#[test]
fn handshake_eof_is_an_error() {
let (host, worker) = proto::socketpair_seqpacket().unwrap();
drop(worker);
assert!(RemoteImporter::from_socket(host, None).is_err());
}
#[test]
fn spawning_a_non_worker_fails_cleanly() {
// `true` exits immediately without a handshake → EOF → clean spawn error, the same
// fallback path a GPU-less box takes.
let Err(err) = RemoteImporter::spawn_exe(Path::new("true")) else {
panic!("spawning a non-worker must fail")
};
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
}
/// A scripted peer: answers the handshake, then serves canned replies per request.
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
let (host, worker) = proto::socketpair_seqpacket().unwrap();
proto::send(
worker.as_fd(),
&Reply::Ready {
version: proto::PROTO_VERSION,
},
None,
)
.unwrap();
let join = thread::spawn(move || {
let mut buf = Vec::new();
let mut seen = Vec::new();
let mut replies = replies.into_iter();
while let Ok((req, _fd)) = proto::recv::<Request>(worker.as_fd(), &mut buf) {
let needs_reply = matches!(req, Request::Modifiers { .. } | Request::Import { .. });
seen.push(req);
if needs_reply {
match replies.next() {
Some(r) => proto::send(worker.as_fd(), &r, None).unwrap(),
None => break, // close → client sees a dead worker
}
}
}
seen
});
let imp = RemoteImporter::from_socket(host, None).unwrap();
(imp, join)
}
#[test]
fn modifiers_round_trip() {
let (mut imp, join) = scripted_server(vec![Reply::Modifiers {
modifiers: vec![1, 2, 3],
}]);
assert_eq!(imp.supported_modifiers(0x3432_5258), vec![1, 2, 3]);
assert!(!imp.dead());
drop(imp);
let seen = join.join().unwrap();
assert_eq!(
seen,
vec![Request::Modifiers {
fourcc: 0x3432_5258
}]
);
}
#[test]
fn need_fd_triggers_one_resend_with_the_fd() {
let (mut imp, join) = scripted_server(vec![
Reply::Err {
message: "one".into(),
},
Reply::NeedFd,
Reply::Err {
message: "two".into(),
},
]);
let (pr, _pw) = std::io::pipe().unwrap();
let plane = DmabufPlane {
fd: pr.as_fd().as_raw_fd(),
offset: 0,
stride: 256,
};
// First import: first sight of the key → fd rides along; the Err reply keeps the key
// marked as sent (the worker cached the fd before failing).
assert!(imp.import(&plane, 64, 64, 1, Some(2)).is_err());
// Second import: no fd (already sent) → worker answers NeedFd → one retry WITH the fd.
assert!(imp.import(&plane, 64, 64, 1, Some(2)).is_err());
assert!(!imp.dead(), "NeedFd handling must not mark the worker dead");
drop(imp);
let fd_flags: Vec<bool> = join
.join()
.unwrap()
.iter()
.map(|r| match r {
Request::Import { has_fd, .. } => *has_fd,
other => panic!("unexpected request {other:?}"),
})
.collect();
assert_eq!(fd_flags, vec![true, false, true]);
}
#[test]
fn import_error_reply_keeps_worker_alive_and_death_is_detected() {
let (mut imp, join) = scripted_server(vec![Reply::Err {
message: "EGL_BAD_MATCH".into(),
}]);
// Any pipe works as a stand-in fd for key derivation.
let (pr, _pw) = std::io::pipe().unwrap();
let plane = DmabufPlane {
fd: pr.as_fd().as_raw_fd(),
offset: 0,
stride: 256,
};
let Err(err) = imp.import(&plane, 64, 64, 1, Some(2)) else {
panic!("scripted Err reply must fail the import")
};
assert!(format!("{err:#}").contains("EGL_BAD_MATCH"));
assert!(!imp.dead(), "an Err reply must not mark the worker dead");
// The scripted replies are exhausted → the server closes → the next import dies.
let Err(err) = imp.import(&plane, 64, 64, 1, Some(2)) else {
panic!("a closed worker must fail the import")
};
assert!(format!("{err:#}").contains("died"), "{err:#}");
assert!(imp.dead());
drop(imp);
let seen = join.join().unwrap();
// First import carried the fd (first sight of the key); the retry didn't re-send it.
match (&seen[0], &seen[1]) {
(
Request::Import {
has_fd: true,
kind: ImportKind::Tiled,
..
},
Request::Import { has_fd: false, .. },
) => {}
other => panic!("unexpected requests {other:?}"),
}
}
}
+11 -159
View File
@@ -90,21 +90,6 @@ pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
/// `struct { char reserved[64]; }`. Plain bytes — safe to ship over a socket.
pub const CU_IPC_HANDLE_SIZE: usize = 64;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUipcMemHandle {
pub reserved: [u8; CU_IPC_HANDLE_SIZE],
}
/// `CUipcMem_flags`: lazily enable peer access on open (the documented flag for
/// `cuIpcOpenMemHandle`; a no-op for a same-device open, which is our only case).
const CU_IPC_MEM_LAZY_ENABLE_PEER_ACCESS: c_uint = 0x1;
/// CUDA Driver API entry points, resolved at runtime from `libcuda.so.1` via `dlopen` rather than
/// a link-time `#[link(name = "cuda")]`. This is what lets ONE host binary run on NVIDIA
/// (zero-copy via CUDA → NVENC) *and* on AMD/Intel (VAAPI, where the NVIDIA driver — and thus
@@ -144,9 +129,6 @@ struct CudaApi {
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
) -> CUresult,
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
}
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
@@ -210,14 +192,6 @@ fn cuda_api() -> Option<&'static CudaApi> {
.get(b"cuExternalMemoryGetMappedBuffer\0")
.ok()?,
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
cuIpcOpenMemHandle: *lib
.get(b"cuIpcOpenMemHandle_v2\0")
.or_else(|_| lib.get(b"cuIpcOpenMemHandle\0"))
.ok()?,
cuIpcCloseMemHandle: *lib.get(b"cuIpcCloseMemHandle\0").ok()?,
};
std::mem::forget(lib); // keep libcuda mapped for the fn pointers' lifetime (process)
Some(api)
@@ -372,28 +346,6 @@ unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUresult {
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuIpcOpenMemHandle(
dptr: *mut CUdeviceptr,
handle: CUipcMemHandle,
flags: c_uint,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcOpenMemHandle)(dptr, handle, flags),
None => CU_ERROR_NOT_LOADED,
}
}
unsafe fn cuIpcCloseMemHandle(dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcCloseMemHandle)(dptr),
None => CU_ERROR_NOT_LOADED,
}
}
#[inline]
fn ck(r: CUresult, what: &str) -> Result<()> {
@@ -435,55 +387,6 @@ pub fn read_plane_to_host(
Ok(host)
}
/// Export a device allocation (from `cuMemAllocPitch`/`cuMemAlloc`) as a cross-process CUDA IPC
/// handle — an opaque 64-byte blob another process opens with [`ipc_open`]. The allocation must
/// stay alive for as long as any importer has it open. The shared context must be current.
pub fn ipc_export(ptr: CUdeviceptr) -> Result<[u8; CU_IPC_HANDLE_SIZE]> {
let mut handle = CUipcMemHandle {
reserved: [0; CU_IPC_HANDLE_SIZE],
};
// SAFETY: `&mut handle` is a live, correctly-sized stack out-param the driver fills with the
// opaque IPC blob; `ptr` is the caller's live device allocation (by-value integer). The call is
// synchronous and retains no pointer into Rust memory. Wrapper → live table (context current).
unsafe { ck(cuIpcGetMemHandle(&mut handle, ptr), "cuIpcGetMemHandle")? };
Ok(handle.reserved)
}
/// Open an IPC handle exported by *another* process ([`ipc_export`]); returns a device pointer
/// valid in this process until [`ipc_close`]. The shared context must be current.
pub fn ipc_open(handle: &[u8; CU_IPC_HANDLE_SIZE]) -> Result<CUdeviceptr> {
let h = CUipcMemHandle { reserved: *handle };
let mut ptr: CUdeviceptr = 0;
// SAFETY: `h` is passed by value (matching the C `CUipcMemHandle` struct ABI); `&mut ptr` is a
// live zero-init stack out-param the driver writes the mapped device address into. Synchronous
// call, distinct locals, no aliasing. Wrapper → live table (context current).
unsafe {
ck(
cuIpcOpenMemHandle(&mut ptr, h, CU_IPC_MEM_LAZY_ENABLE_PEER_ACCESS),
"cuIpcOpenMemHandle",
)?
};
Ok(ptr)
}
/// Close a mapping opened with [`ipc_open`] (best-effort teardown; makes the shared context
/// current itself since drops may run off-thread).
pub fn ipc_close(ptr: CUdeviceptr) {
if ptr == 0 {
return;
}
// SAFETY: `ptr` is a device pointer previously returned by `cuIpcOpenMemHandle` (the only
// caller path), closed exactly once by the owning cache. We make the shared context current
// first because this runs from `Drop` on whatever thread holds the last reference. Result
// ignored (best-effort teardown). Wrapper → live table (the mapping exists ⇒ driver present).
unsafe {
if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0);
}
let _ = cuIpcCloseMemHandle(ptr);
}
}
/// The shared process-wide CUDA context (created once). Wrapped so it's `Send`/`Sync` to live
/// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread.
#[derive(Clone, Copy)]
@@ -773,7 +676,6 @@ impl BufferPool {
height: self.height,
uv: Some((uv_ptr, uv_pitch)),
pool: Some(self.inner.clone()),
remote_release: None,
});
}
let reuse = self.inner.lock().unwrap().free.pop();
@@ -788,7 +690,6 @@ impl BufferPool {
height: self.height,
uv: None,
pool: Some(self.inner.clone()),
remote_release: None,
})
}
}
@@ -805,10 +706,6 @@ pub struct DeviceBuffer {
/// `None` for the default 4-byte RGB/BGRx path. When `Some`, [`ptr`] is the Y plane (1 byte/px).
pub uv: Option<(CUdeviceptr, usize)>,
pool: Option<Arc<Mutex<PoolInner>>>,
/// Set for buffers whose device memory is owned by ANOTHER process (the zero-copy import
/// worker, reached via CUDA IPC): drop runs this exactly once (telling the owner to recycle)
/// and must neither free nor pool-recycle the pointers locally.
remote_release: Option<Box<dyn FnOnce() + Send>>,
}
impl DeviceBuffer {
@@ -822,7 +719,6 @@ impl DeviceBuffer {
height,
uv: None,
pool: None,
remote_release: None,
})
}
@@ -837,7 +733,6 @@ impl DeviceBuffer {
height,
uv: Some((uv_ptr, uv_pitch)),
pool: None,
remote_release: None,
})
}
@@ -845,38 +740,10 @@ impl DeviceBuffer {
pub fn is_nv12(&self) -> bool {
self.uv.is_some()
}
/// Wrap device planes owned by ANOTHER process (opened here via [`ipc_open`]) as a frame
/// buffer. `release` runs exactly once on drop — it tells the owning process to recycle the
/// buffer; nothing is freed or pooled locally (the IPC mapping itself is closed by the cache
/// that opened it, after the last remote buffer referencing it has dropped).
pub fn remote(
ptr: CUdeviceptr,
pitch: usize,
width: u32,
height: u32,
uv: Option<(CUdeviceptr, usize)>,
release: Box<dyn FnOnce() + Send>,
) -> DeviceBuffer {
DeviceBuffer {
ptr,
pitch,
width,
height,
uv,
pool: None,
remote_release: Some(release),
}
}
}
impl Drop for DeviceBuffer {
fn drop(&mut self) {
if let Some(release) = self.remote_release.take() {
// Remote (IPC) buffer: the worker owns the memory — just hand it back.
release();
return;
}
if self.ptr == 0 {
return;
}
@@ -1121,34 +988,19 @@ pub fn copy_nv12_to_device(
}
}
impl RegisteredTexture {
/// Unregister now (idempotent; the later `Drop` then no-ops). Teardown-order helper: the blit
/// destructors call this to release the CUDA registration BEFORE deleting the GL texture it
/// wraps — deleting a still-registered texture leaves the driver holding a registration onto
/// freed GL state, exactly the stale-driver-state class this path once crashed on.
pub fn release(&mut self) {
if self.resource.is_null() {
return;
}
// SAFETY: `self.resource` is non-null (just checked) and is the valid `CUgraphicsResource`
// from `register_gl`, owned exclusively by this `RegisteredTexture`; nulling the field
// right after makes this (and the `Drop` below) unregister it exactly once — no
// use-after-free or double-unregister. We make the shared context current first because a
// release may run during teardown on a thread where it isn't. Wrapper → live table (the
// resource exists ⇒ the driver was present). Result ignored (best-effort teardown).
unsafe {
if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0);
}
let _ = cuGraphicsUnregisterResource(self.resource);
}
self.resource = std::ptr::null_mut();
}
}
impl Drop for RegisteredTexture {
fn drop(&mut self) {
self.release();
if !self.resource.is_null() {
// SAFETY: `self.resource` is non-null (just checked) and is the valid
// `CUgraphicsResource` from `register_gl`, owned exclusively by this `RegisteredTexture`
// and unregistered exactly once here (drop runs once) — no use-after-free or
// double-unregister. `cuGraphicsUnregisterResource` releases the GL↔CUDA registration;
// wrapper → live table (the resource exists ⇒ the driver was present). Result ignored
// (best-effort teardown).
unsafe {
let _ = cuGraphicsUnregisterResource(self.resource);
}
}
}
}
@@ -270,27 +270,6 @@ impl GlBlit {
}
}
impl Drop for GlBlit {
fn drop(&mut self) {
// Unregister the CUDA graphics resource BEFORE deleting the GL texture it wraps (see
// `Nv12Blit::drop` — same ordering hazard). Previously `GlBlit` had no `Drop` at all, so
// its GL objects leaked on every size change and on importer teardown.
self.registered.release();
// SAFETY: these GL names were all created by THIS `GlBlit` in `GlBlit::new` on the current
// GL context, still current here (the owning `EglImporter` drops on its single capture
// thread and never releases the context). Each `glDelete*` gets a count of 1 and a `&u32`
// to one live field; the symbols dispatch through libGL to the driver for the current
// context. Each name is deleted exactly once, after its CUDA registration was released.
unsafe {
glDeleteTextures(1, &self.dst_tex);
glDeleteTextures(1, &self.src_tex);
glDeleteFramebuffers(1, &self.fbo);
glDeleteVertexArrays(1, &self.vao);
glDeleteProgram(self.program);
}
}
}
/// Per-size GL machinery to convert a dmabuf EGLImage into an NV12 (BT.709 limited-range) pair —
/// the [`GlBlit`] analogue for the `PUNKTFUNK_NV12` path. Two passes share `src_tex`: a full-res Y
/// pass into a CUDA-registrable `GL_R8` texture and a half-res UV pass into a `GL_RG8` texture.
@@ -438,12 +417,6 @@ impl Nv12Blit {
impl Drop for Nv12Blit {
fn drop(&mut self) {
// Unregister the CUDA graphics resources BEFORE deleting the GL textures they wrap.
// `Drop::drop` runs before the fields' own drops, so without this the `glDeleteTextures`
// below would destroy `y_tex`/`uv_tex` while still CUDA-registered — leaving the driver a
// registration onto freed GL state (the stale-driver-state class that crashed this path).
self.y_registered.release();
self.uv_registered.release();
// SAFETY: these GL names (textures/FBOs/VAO/programs) were all created by THIS `Nv12Blit`
// in `Nv12Blit::new` on the current GL context, which is still current because the owning
// `EglImporter` is dropped on its single capture thread (fields drop before
@@ -451,8 +424,7 @@ impl Drop for Nv12Blit {
// pointer to that many names: `&self.y_tex`/`&self.vao` are `&u32` to one live field (n=1);
// `[self.y_fbo, self.uv_fbo].as_ptr()` points at a 2-element temporary that lives for the
// whole `glDeleteFramebuffers` call (n=2 matches). The symbols dispatch through libGL
// (libglvnd) to the driver for the current context. Each name is deleted exactly once,
// after its CUDA registration was released above.
// (libglvnd) to the driver for the current context. Each name is deleted exactly once.
unsafe {
glDeleteTextures(1, &self.y_tex);
glDeleteTextures(1, &self.uv_tex);
@@ -665,22 +637,6 @@ impl EglImporter {
)
}
/// Drop the Vulkan bridge's cached per-fd import (see [`super::vulkan::VkBridge::forget_fd`]).
/// No-op when the bridge hasn't been built (tiled-only captures).
pub fn forget_linear_fd(&mut self, fd: i32) {
if let Some(vk) = self.vk.as_mut() {
vk.forget_fd(fd);
}
}
/// Tear down the whole LINEAR-path import cache (the Vulkan bridge and every per-fd source
/// buffer in it). Called when the PipeWire stream renegotiates — the buffer pool the cache
/// keyed on is gone, and a recycled fd number must never resolve to a stale import. The
/// bridge lazily rebuilds on the next LINEAR frame (renegotiations are rare).
pub fn clear_linear_cache(&mut self) {
self.vk = None;
}
/// The DRM format modifiers the NVIDIA EGL stack can import for `fourcc`, via
/// `eglQueryDmaBufModifiersEXT`. We advertise these to PipeWire so the compositor allocates
/// a dmabuf in a layout we can import. Empty on failure (caller falls back).
+10 -157
View File
@@ -10,14 +10,11 @@
//! headless EGLDisplay + dmabuf→`EGLImage`→CUDA import). The encoder's CUDA-frame path lives in
//! `encode/linux.rs`; the dmabuf negotiation lives in `capture/linux.rs`.
pub mod client;
pub mod cuda;
pub mod egl;
pub mod proto;
pub mod vulkan;
pub mod worker;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
pub use cuda::DeviceBuffer;
pub use egl::{DmabufPlane, EglImporter};
@@ -51,21 +48,18 @@ pub fn vaapi_dmabuf_forced() -> bool {
flag_opt("PUNKTFUNK_ZEROCOPY") == Some(true)
}
/// Whether the zero-copy path is on. `PUNKTFUNK_ZEROCOPY` decides when set (truthy = on, else off).
/// **Unset defaults ON for both GPU backends** — the stock install gets the GPU dmabuf path, not
/// three full-frame CPU touches. This includes NVENC (previously opt-in): the EGL→CUDA (tiled) and
/// Vulkan (LINEAR) imports now run in a per-capture worker subprocess
/// (`design/zerocopy-worker-isolation.md`), so a driver fault on a producer-invalidated dmabuf kills
/// the worker and the host degrades to its capture-loss rebuild instead of dying — the reason the
/// NVENC path stayed opt-in is gone. Fallbacks stay in place: VAAPI has a one-shot CPU downgrade if
/// the LINEAR-dmabuf offer never negotiates ([`note_vaapi_dmabuf_failed`]); NVENC falls back per
/// capture when no importer/importable modifier is available and latches the import off after
/// repeated worker deaths. `PUNKTFUNK_ZEROCOPY=0` opts out; `PUNKTFUNK_FORCE_SHM` forces the
/// race-free SHM path.
/// Whether the zero-copy path is on. `PUNKTFUNK_ZEROCOPY` decides when set (truthy = on, else
/// off). Unset defaults **on for the VAAPI (AMD/Intel) backend** — the stock AMD/Intel install
/// gets the GPU dmabuf path, not three full-frame CPU touches — unless a failed negotiation
/// downgraded it ([`note_vaapi_dmabuf_failed`]); and **off for NVENC**, whose EGL→CUDA import
/// stays opt-in (Mutter+NVIDIA has known dmabuf-capture races; see `PUNKTFUNK_FORCE_SHM`).
pub fn enabled() -> bool {
match flag_opt("PUNKTFUNK_ZEROCOPY") {
Some(v) => v,
None => !VAAPI_DMABUF_FAILED.load(Ordering::Relaxed),
None => {
crate::encode::linux_zero_copy_is_vaapi()
&& !VAAPI_DMABUF_FAILED.load(Ordering::Relaxed)
}
}
}
@@ -79,127 +73,6 @@ pub fn nv12_enabled() -> bool {
flag_opt("PUNKTFUNK_NV12").unwrap_or(true)
}
/// The GPU importer a capture uses — normally the [`client::RemoteImporter`] worker subprocess
/// (design: `design/zerocopy-worker-isolation.md`), so a driver fault on a producer-invalidated
/// dmabuf kills the worker instead of the host. `PUNKTFUNK_ZEROCOPY_INPROC=1` keeps the import
/// in-process (the pre-isolation behavior) for debugging and A/B latency comparison.
pub enum Importer {
Remote(client::RemoteImporter),
InProc(Box<EglImporter>),
}
impl Importer {
/// Build the importer for a capture session, honoring the `PUNKTFUNK_ZEROCOPY_INPROC`
/// escape hatch. An `Err` means "no GPU import available" — callers fall back to the CPU path.
pub fn new_for_capture() -> anyhow::Result<Importer> {
if flag("PUNKTFUNK_ZEROCOPY_INPROC") {
tracing::warn!(
"PUNKTFUNK_ZEROCOPY_INPROC=1 — GPU import runs IN-PROCESS; a driver fault on a \
dying compositor's dmabuf can take the whole host down (debug/A-B use only)"
);
return Ok(Importer::InProc(Box::new(EglImporter::new()?)));
}
Ok(Importer::Remote(client::RemoteImporter::spawn()?))
}
pub fn supported_modifiers(&mut self, fourcc: u32) -> Vec<u64> {
match self {
Importer::Remote(r) => r.supported_modifiers(fourcc),
Importer::InProc(i) => i.supported_modifiers(fourcc),
}
}
pub fn import(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
fourcc: u32,
modifier: Option<u64>,
) -> anyhow::Result<DeviceBuffer> {
match self {
Importer::Remote(r) => r.import(plane, width, height, fourcc, modifier),
Importer::InProc(i) => i.import(plane, width, height, fourcc, modifier),
}
}
pub fn import_nv12(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
fourcc: u32,
modifier: Option<u64>,
) -> anyhow::Result<DeviceBuffer> {
match self {
Importer::Remote(r) => r.import_nv12(plane, width, height, fourcc, modifier),
Importer::InProc(i) => i.import_nv12(plane, width, height, fourcc, modifier),
}
}
pub fn import_linear(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
) -> anyhow::Result<DeviceBuffer> {
match self {
Importer::Remote(r) => r.import_linear(plane, width, height),
Importer::InProc(i) => i.import_linear(plane, width, height),
}
}
/// True once the worker process is gone/wedged (every further call fails fast). Always
/// `false` in-process — an in-process driver fault doesn't return.
pub fn dead(&self) -> bool {
match self {
Importer::Remote(r) => r.dead(),
Importer::InProc(_) => false,
}
}
/// The PipeWire stream renegotiated its format (the buffer pool is replaced) — drop all
/// per-buffer caches so a recycled fd number can never resolve to a stale import.
pub fn clear_cache(&mut self) {
match self {
Importer::Remote(r) => r.clear_cache(),
Importer::InProc(i) => i.clear_linear_cache(),
}
}
}
/// Consecutive zero-copy worker deaths without a successful import in between. A short streak is
/// normal (the observed trigger — a compositor crash — kills the worker once, and the rebuilt
/// session's fresh worker succeeds); a sustained streak means the GPU stack itself is wedged and
/// respawning would crash-loop, so [`note_gpu_import_death`] latches [`GPU_IMPORT_DISABLED`] and
/// every later capture negotiates the safe CPU/SHM path instead.
static GPU_IMPORT_DEATH_STREAK: AtomicU32 = AtomicU32::new(0);
static GPU_IMPORT_DISABLED: AtomicBool = AtomicBool::new(false);
const GPU_IMPORT_DEATH_LATCH: u32 = 3;
/// Record a worker death (transport-level failure). Latches the process-wide disable after
/// [`GPU_IMPORT_DEATH_LATCH`] consecutive deaths.
pub fn note_gpu_import_death() {
let streak = GPU_IMPORT_DEATH_STREAK.fetch_add(1, Ordering::Relaxed) + 1;
if streak >= GPU_IMPORT_DEATH_LATCH && !GPU_IMPORT_DISABLED.swap(true, Ordering::Relaxed) {
tracing::error!(
streak,
"zero-copy GPU import disabled for this host process: the import worker died {streak} \
times in a row (GPU/driver stack unstable) captures fall back to the CPU path"
);
}
}
/// Record a successful GPU import — resets the death streak (the stack works again).
pub fn note_gpu_import_ok() {
GPU_IMPORT_DEATH_STREAK.store(0, Ordering::Relaxed);
}
/// True once repeated worker deaths latched the GPU import off (see [`note_gpu_import_death`]).
pub fn gpu_import_disabled() -> bool {
GPU_IMPORT_DISABLED.load(Ordering::Relaxed)
}
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
const fn fourcc(c: &[u8; 4]) -> u32 {
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)
@@ -377,23 +250,3 @@ pub fn nv12_selftest() -> anyhow::Result<()> {
bail!("NV12 self-test FAILED (Y={max_y_err:.2} U={max_u_err:.2} V={max_v_err:.2})");
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Single test owning the process-global latch statics (they are never reset by design).
#[test]
fn gpu_import_death_latch() {
note_gpu_import_death();
note_gpu_import_ok(); // a successful import resets the streak
note_gpu_import_death();
note_gpu_import_death();
assert!(
!gpu_import_disabled(),
"two consecutive deaths must not latch"
);
note_gpu_import_death(); // third consecutive death
assert!(gpu_import_disabled());
}
}
@@ -1,390 +0,0 @@
//! Wire protocol between the PipeWire capture thread and the isolated zero-copy GPU-import
//! worker process (`punktfunk-host zerocopy-worker`; design:
//! [`design/zerocopy-worker-isolation.md`]). Transport is a `SOCK_SEQPACKET` unix socketpair —
//! reliable, ordered, message-framed (one `sendmsg` = one message) — with dmabuf fds riding as
//! `SCM_RIGHTS` control data. Bodies are small serde_json blobs (~200 B/frame); pixels never
//! cross the socket (they move GPU-side via CUDA IPC, see [`super::cuda::ipc_export`]).
//!
//! Zero-length messages are reserved: `recvmsg` returning 0 on a SEQPACKET socket is EOF (the
//! peer died/closed), and every serialized message here is non-empty JSON, so the two can't be
//! confused.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::io;
use std::os::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
use std::time::Duration;
/// Bumped on any wire change; the worker echoes it in [`Reply::Ready`] and the host refuses a
/// mismatch. Host and worker are the same binary (`/proc/self/exe`), so this only ever trips on
/// exotic deployment mistakes (a stale binary re-exec'd across an upgrade).
pub const PROTO_VERSION: u32 = 1;
/// Upper bound for one serialized message (the largest real message — a modifier list — is far
/// below this). A message reported truncated at this size is a protocol error.
pub const MAX_MSG: usize = 64 * 1024;
/// How a dmabuf should be imported — mirrors the three `EglImporter` entry points.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportKind {
/// Tiled dmabuf → EGL/GL de-tile blit → BGRx CUDA buffer.
Tiled,
/// Tiled dmabuf → EGL/GL NV12 convert → two-plane CUDA buffer (`PUNKTFUNK_NV12`).
TiledNv12,
/// LINEAR dmabuf → Vulkan bridge → BGRx CUDA buffer (gamescope's only offer).
Linear,
}
/// host → worker.
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum Request {
/// The EGL-importable DRM modifiers for `fourcc` (startup, before the stream connects —
/// the host advertises these to PipeWire).
Modifiers { fourcc: u32 },
/// Import one frame. `key` identifies the underlying dmabuf across frames (the host uses
/// the fd's `st_ino` — unique per dma-buf object); the fd itself rides along as
/// `SCM_RIGHTS` only on first sight of `key` (`has_fd`), and the worker keeps its dup.
Import {
key: u64,
kind: ImportKind,
width: u32,
height: u32,
fourcc: u32,
modifier: Option<u64>,
offset: u32,
stride: u32,
has_fd: bool,
},
/// The frame buffer previously delivered as `id` is no longer in use — recycle it into the
/// worker's pool. Fire-and-forget (no reply); may be sent from any host thread.
Release { id: u32 },
/// The PipeWire stream renegotiated its format: the buffer pool is gone, so drop all cached
/// per-`key` state (stored fds, Vulkan per-fd imports). Fire-and-forget.
ClearCache,
}
/// worker → host.
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum Reply {
/// Sent once at startup after EGL + CUDA came up.
Ready {
version: u32,
},
/// Startup failed (no NVIDIA driver, EGL error, …) — the host falls back to the CPU path,
/// exactly like an in-process `EglImporter::new()` failure.
InitErr {
message: String,
},
Modifiers {
modifiers: Vec<u64>,
},
/// The imported frame is complete (the GPU copy already synced worker-side) in buffer `id`.
/// `desc` rides along the first time `id` is ever delivered — the host opens its CUDA IPC
/// handles once and caches the mapping for every later frame in the same buffer.
Frame {
id: u32,
desc: Option<BufferDesc>,
},
/// The worker has no cached fd for the import's `key` (evicted, or the two sides' caches
/// diverged) — the host forgets its "already sent" note and retries once WITH the fd.
NeedFd,
/// This import failed but the worker is alive (e.g. `EGL_BAD_MATCH` on one buffer).
Err {
message: String,
},
}
/// CUDA IPC identity of one pooled device buffer (sent once per buffer, then referenced by id).
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct BufferDesc {
pub width: u32,
pub height: u32,
/// `cuIpcGetMemHandle` blob for the (Y or BGRx) plane — exactly 64 bytes.
pub y_handle: Vec<u8>,
pub y_pitch: usize,
/// NV12 only: the interleaved chroma plane's `(handle, pitch)`.
pub uv: Option<(Vec<u8>, usize)>,
}
/// A CLOEXEC `SOCK_SEQPACKET` socketpair — `(host_end, worker_end)`.
pub fn socketpair_seqpacket() -> io::Result<(OwnedFd, OwnedFd)> {
let mut fds = [0i32; 2];
// SAFETY: `socketpair` writes two fds into `fds`, a live 2-element stack array matching the
// API contract; it reads no other Rust memory. The result is checked before the fds are used,
// and each returned fd is fresh (owned by no other wrapper), so the two `OwnedFd::from_raw_fd`
// each take sole ownership of a distinct, valid descriptor — no alias, no double-close.
unsafe {
if libc::socketpair(
libc::AF_UNIX,
libc::SOCK_SEQPACKET | libc::SOCK_CLOEXEC,
0,
fds.as_mut_ptr(),
) != 0
{
return Err(io::Error::last_os_error());
}
Ok((OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])))
}
}
/// Set (or clear) the receive timeout: a blocked [`recv`] then fails with
/// `ErrorKind::WouldBlock`. Used by the host so a hung worker can't wedge the capture thread.
pub fn set_recv_timeout(sock: BorrowedFd, timeout: Option<Duration>) -> io::Result<()> {
let tv = match timeout {
Some(d) => libc::timeval {
tv_sec: d.as_secs() as libc::time_t,
tv_usec: d.subsec_micros() as libc::suseconds_t,
},
None => libc::timeval {
tv_sec: 0,
tv_usec: 0,
},
};
// SAFETY: `setsockopt(SO_RCVTIMEO)` reads `size_of::<timeval>()` bytes from `&tv`, a live
// stack `timeval` that outlives this synchronous call; `sock` is the caller's live socket fd.
// Nothing is retained or written through Rust pointers.
let r = unsafe {
libc::setsockopt(
sock.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_RCVTIMEO,
&tv as *const libc::timeval as *const libc::c_void,
std::mem::size_of::<libc::timeval>() as libc::socklen_t,
)
};
if r != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
/// Send one message (+ optionally one fd as `SCM_RIGHTS`) as a single SEQPACKET datagram.
/// Atomic per message, so concurrent senders on the same socket (the capture thread's imports,
/// the encode thread's releases) need no lock. `MSG_NOSIGNAL` turns a dead peer into `EPIPE`
/// instead of `SIGPIPE`.
pub fn send<T: Serialize>(
sock: BorrowedFd,
msg: &T,
pass_fd: Option<BorrowedFd>,
) -> io::Result<()> {
let body =
serde_json::to_vec(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
debug_assert!(
!body.is_empty(),
"zero-length messages are reserved for EOF"
);
if body.len() > MAX_MSG {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"zerocopy proto message too large",
));
}
let mut iov = libc::iovec {
iov_base: body.as_ptr() as *mut libc::c_void,
iov_len: body.len(),
};
// Control buffer for one fd: CMSG_SPACE(4) = 24 bytes on 64-bit; [u64; 4] gives 32 bytes at
// the 8-byte alignment `cmsghdr` requires.
let mut cmsg_store = [0u64; 4];
// SAFETY: `mhdr` is a plain-old-data C struct for which all-zero is a valid value.
let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() };
mhdr.msg_iov = &mut iov;
mhdr.msg_iovlen = 1;
if let Some(fd) = pass_fd {
mhdr.msg_control = cmsg_store.as_mut_ptr() as *mut libc::c_void;
// SAFETY: `CMSG_SPACE`/`CMSG_LEN` are pure size computations (no memory access).
// `CMSG_FIRSTHDR(&mhdr)` returns a pointer into `cmsg_store` (non-null: msg_controllen
// ≥ one cmsghdr), which is live, 8-aligned, and large enough (32 ≥ CMSG_SPACE(4) = 24)
// for the header fields and the 4-byte fd written via `CMSG_DATA`; `write_unaligned`
// handles the data area's byte alignment. All writes stay within `cmsg_store`, which
// outlives the synchronous `sendmsg` below.
unsafe {
mhdr.msg_controllen = libc::CMSG_SPACE(4) as _;
let c = libc::CMSG_FIRSTHDR(&mhdr);
(*c).cmsg_level = libc::SOL_SOCKET;
(*c).cmsg_type = libc::SCM_RIGHTS;
(*c).cmsg_len = libc::CMSG_LEN(4) as _;
std::ptr::write_unaligned(libc::CMSG_DATA(c) as *mut i32, fd.as_raw_fd());
}
}
// SAFETY: `sock` is the caller's live socket; `mhdr` points at the live `iov` (over `body`,
// which outlives the call) and — when an fd is passed — at `cmsg_store` (ditto). `sendmsg`
// only reads these buffers. The kernel dups the fd into the message; our `BorrowedFd` stays
// owned by the caller.
let n = unsafe { libc::sendmsg(sock.as_raw_fd(), &mhdr, libc::MSG_NOSIGNAL) };
if n < 0 {
return Err(io::Error::last_os_error());
}
if n as usize != body.len() {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"short sendmsg on SEQPACKET socket",
));
}
Ok(())
}
/// Receive one message (+ up to one `SCM_RIGHTS` fd). `buf` is a caller-owned scratch buffer
/// (grown to [`MAX_MSG`] once, then reused frame to frame). Errors:
/// `UnexpectedEof` = the peer is gone; `WouldBlock` = the [`set_recv_timeout`] expired.
pub fn recv<T: DeserializeOwned>(
sock: BorrowedFd,
buf: &mut Vec<u8>,
) -> io::Result<(T, Option<OwnedFd>)> {
buf.resize(MAX_MSG, 0);
let mut iov = libc::iovec {
iov_base: buf.as_mut_ptr() as *mut libc::c_void,
iov_len: buf.len(),
};
let mut cmsg_store = [0u64; 4];
// SAFETY: `mhdr` is a plain-old-data C struct for which all-zero is a valid value.
let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() };
mhdr.msg_iov = &mut iov;
mhdr.msg_iovlen = 1;
mhdr.msg_control = cmsg_store.as_mut_ptr() as *mut libc::c_void;
mhdr.msg_controllen = std::mem::size_of_val(&cmsg_store) as _;
// SAFETY: `sock` is the caller's live socket. `recvmsg` writes at most `iov_len` bytes into
// `buf` (live for the call) and at most `msg_controllen` control bytes into `cmsg_store`
// (live, 8-aligned). `MSG_CMSG_CLOEXEC` makes any received fd CLOEXEC atomically.
let n = unsafe { libc::recvmsg(sock.as_raw_fd(), &mut mhdr, libc::MSG_CMSG_CLOEXEC) };
if n < 0 {
return Err(io::Error::last_os_error());
}
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"zerocopy proto peer closed",
));
}
// Collect a passed fd (if any) BEFORE any early return below, so it can't leak.
let mut got_fd: Option<OwnedFd> = None;
// SAFETY: `CMSG_FIRSTHDR`/`CMSG_NXTHDR` walk the control area the kernel just wrote inside
// `cmsg_store` (bounded by the updated `mhdr.msg_controllen`), returning either null or a
// pointer to a complete `cmsghdr` within it — each dereference reads kernel-initialized
// fields in bounds. For an `SCM_RIGHTS` cmsg the data area holds whole `i32` fds; we read the
// first via `read_unaligned`. The kernel gave us ownership of that fd (it is a fresh
// descriptor in our table), so `OwnedFd::from_raw_fd` takes sole ownership — any previously
// collected `got_fd` is dropped (closed) first, so nothing leaks even with multiple cmsgs.
unsafe {
let mut c = libc::CMSG_FIRSTHDR(&mhdr);
while !c.is_null() {
if (*c).cmsg_level == libc::SOL_SOCKET && (*c).cmsg_type == libc::SCM_RIGHTS {
let fd = std::ptr::read_unaligned(libc::CMSG_DATA(c) as *const i32);
if fd >= 0 {
got_fd = Some(OwnedFd::from_raw_fd(fd));
}
}
c = libc::CMSG_NXTHDR(&mhdr, c);
}
}
if mhdr.msg_flags & libc::MSG_TRUNC != 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"zerocopy proto message truncated",
));
}
let msg = serde_json::from_slice(&buf[..n as usize])
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok((msg, got_fd))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::os::fd::AsFd;
#[test]
fn round_trip_no_fd() {
let (a, b) = socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
let req = Request::Import {
key: 0xdead_beef_u64,
kind: ImportKind::TiledNv12,
width: 5120,
height: 1440,
fourcc: 0x3432_5258,
modifier: Some(0x0300_0000_0000_1234),
offset: 0,
stride: 5120 * 4,
has_fd: false,
};
send(a.as_fd(), &req, None).unwrap();
let (got, fd) = recv::<Request>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got, req);
assert!(fd.is_none());
let reply = Reply::Frame {
id: 7,
desc: Some(BufferDesc {
width: 5120,
height: 1440,
y_handle: vec![1u8; 64],
y_pitch: 5632,
uv: Some((vec![2u8; 64], 5632)),
}),
};
send(b.as_fd(), &reply, None).unwrap();
let (got, fd) = recv::<Reply>(a.as_fd(), &mut buf).unwrap();
assert_eq!(got, reply);
assert!(fd.is_none());
}
#[test]
fn passes_an_fd() {
let (a, b) = socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
// A pipe stands in for a dmabuf: pass the read end, write through the original write end,
// and read the bytes back through the RECEIVED fd.
let (mut pr, mut pw) = std::io::pipe().unwrap();
send(a.as_fd(), &Request::ClearCache, Some(pr.as_fd())).unwrap();
let (got, fd) = recv::<Request>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got, Request::ClearCache);
let fd = fd.expect("fd should have been passed");
pw.write_all(b"hello").unwrap();
drop(pw);
let mut file = std::fs::File::from(fd);
let mut s = String::new();
file.read_to_string(&mut s).unwrap();
assert_eq!(s, "hello");
// The original read end still works independently of the passed dup.
let mut nothing = [0u8; 1];
assert_eq!(pr.read(&mut nothing).unwrap(), 0);
}
#[test]
fn eof_when_peer_closes() {
let (a, b) = socketpair_seqpacket().unwrap();
drop(a);
let mut buf = Vec::new();
let err = recv::<Reply>(b.as_fd(), &mut buf).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
}
#[test]
fn send_to_dead_peer_is_epipe_not_sigpipe() {
let (a, b) = socketpair_seqpacket().unwrap();
drop(b);
let err = send(a.as_fd(), &Request::ClearCache, None).unwrap_err();
// MSG_NOSIGNAL: a dead peer surfaces as EPIPE (BrokenPipe), never a process-killing signal.
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
}
#[test]
fn recv_timeout_fires() {
let (a, _b) = socketpair_seqpacket().unwrap();
set_recv_timeout(a.as_fd(), Some(Duration::from_millis(50))).unwrap();
let mut buf = Vec::new();
let err = recv::<Reply>(a.as_fd(), &mut buf).unwrap_err();
assert!(
matches!(
err.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
),
"unexpected error kind: {err:?}"
);
}
}
@@ -302,23 +302,6 @@ impl VkBridge {
Ok(())
}
/// Drop the cached import for `fd` (the PipeWire buffer it wrapped is gone — pool recycle /
/// renegotiation — or the caller is about to store a different dmabuf under the same slot).
/// Without this the cache could serve a stale imported buffer for a reused fd number, or
/// leak an entry per recycled pool buffer.
pub fn forget_fd(&mut self, fd: i32) {
if let Some(s) = self.src_cache.remove(&fd) {
// SAFETY: `s.buffer`/`s.memory` were created by this bridge's `import_src` and are
// exclusively owned by the removed cache entry, so each is destroyed exactly once.
// No GPU work can still reference them: every `import_linear` fence-waits its copy to
// completion before returning, and this runs on the same single owning thread.
unsafe {
self.device.destroy_buffer(s.buffer, None);
self.device.free_memory(s.memory, None);
}
}
}
/// Bridge one LINEAR dmabuf frame into a pooled CUDA buffer: GPU copy dmabuf→exportable,
/// then pitched CUDA copy exportable→`pool` buffer.
pub fn import_linear(
@@ -1,465 +0,0 @@
//! The isolated zero-copy GPU-import worker (`punktfunk-host zerocopy-worker`; design:
//! [`design/zerocopy-worker-isolation.md`]). It owns the fragile driver stack — the headless
//! EGLDisplay + GL context, the CUDA context, and the Vulkan bridge — so that a driver fault on a
//! producer-invalidated dmabuf (the `cuGraphicsMapResources` SIGSEGV the F44 Game→Desktop switch
//! reproduced) kills THIS process, not the streaming host. The host observes the dead socket,
//! fails the frame cleanly, and its existing capture-loss rebuild takes over.
//!
//! One worker serves one capture (spawned per `pipewire_thread`). It exits on socket EOF — which
//! only happens after the capturer AND every in-flight frame on the host side are gone, so pooled
//! device memory is never freed under a frame the host still reads.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, CUdeviceptr, DeviceBuffer};
use super::egl::{DmabufPlane, EglImporter};
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
use anyhow::{bail, Context, Result};
use std::collections::{HashMap, VecDeque};
use std::io;
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
/// Cap on cached per-key dmabuf fds. PipeWire buffer pools are ≤ ~16 buffers; the cap only
/// matters if a misbehaving producer churns buffers without a renegotiation.
const FD_CACHE_CAP: usize = 64;
/// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
pub fn run_from_args(args: &[String]) -> Result<()> {
let fd: i32 = args
.iter()
.skip_while(|a| *a != "--fd")
.nth(1)
.map(|s| s.parse())
.transpose()
.context("parse --fd")?
.unwrap_or(3);
// SAFETY: the spawning host `dup2`'d its socketpair end onto exactly this fd number before
// exec (the subcommand's contract) and nothing else in this fresh process owns it, so
// `OwnedFd` takes sole ownership and closes it exactly once at exit.
let sock = unsafe { OwnedFd::from_raw_fd(fd) };
run(sock)
}
/// Bring up the GPU stack, report readiness, and serve until the host goes away.
fn run(sock: OwnedFd) -> Result<()> {
let importer = match EglImporter::new() {
Ok(i) => i,
Err(e) => {
// Init failure is an ANSWER, not a crash: the host falls back to the CPU path,
// exactly like an in-process `EglImporter::new()` failure.
let _ = proto::send(
sock.as_fd(),
&Reply::InitErr {
message: format!("{e:#}"),
},
None,
);
return Ok(());
}
};
proto::send(
sock.as_fd(),
&Reply::Ready {
version: proto::PROTO_VERSION,
},
None,
)
.context("send Ready")?;
tracing::info!(pid = std::process::id(), "zerocopy import worker ready");
let mut backend = EglBackend::new(importer);
serve(&sock, &mut backend)
}
/// What [`serve`] needs from an import implementation — split out so the dispatch loop is
/// unit-testable without a GPU.
pub(crate) trait ImportBackend {
fn modifiers(&mut self, fourcc: u32) -> Vec<u64>;
/// Answers with [`Reply::Frame`] (buffer id + [`BufferDesc`] iff first delivery of that id),
/// [`Reply::NeedFd`] (this side lacks the key's fd — host resends it once), or [`Reply::Err`].
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply;
fn release(&mut self, id: u32);
fn clear_cache(&mut self);
}
/// The [`Request::Import`] fields, destructured for [`ImportBackend::import`].
pub(crate) struct ImportReq {
pub key: u64,
pub kind: ImportKind,
pub width: u32,
pub height: u32,
pub fourcc: u32,
pub modifier: Option<u64>,
pub offset: u32,
pub stride: u32,
pub has_fd: bool,
}
/// The request loop. Returns `Ok(())` on host EOF (normal end-of-life); any other socket error
/// propagates (the process exits — the host treats it like a death, which it is).
pub(crate) fn serve(sock: &OwnedFd, backend: &mut dyn ImportBackend) -> Result<()> {
let mut buf = Vec::new();
loop {
let (req, fd) = match proto::recv::<Request>(sock.as_fd(), &mut buf) {
Ok(v) => v,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(()),
Err(e) => return Err(e).context("worker recv"),
};
match req {
Request::Modifiers { fourcc } => {
let reply = Reply::Modifiers {
modifiers: backend.modifiers(fourcc),
};
if send_or_eof(sock, &reply)? {
return Ok(());
}
}
Request::Import {
key,
kind,
width,
height,
fourcc,
modifier,
offset,
stride,
has_fd,
} => {
let req = ImportReq {
key,
kind,
width,
height,
fourcc,
modifier,
offset,
stride,
has_fd,
};
let reply = backend.import(&req, fd);
if send_or_eof(sock, &reply)? {
return Ok(());
}
}
Request::Release { id } => backend.release(id),
Request::ClearCache => backend.clear_cache(),
}
}
}
/// Send a reply; `Ok(true)` means the host is gone (EPIPE) and the loop should end quietly.
fn send_or_eof(sock: &OwnedFd, reply: &Reply) -> Result<bool> {
match proto::send(sock.as_fd(), reply, None) {
Ok(()) => Ok(false),
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(true),
Err(e) => Err(e).context("worker send"),
}
}
/// The real backend: the in-process [`EglImporter`] plus the cross-process bookkeeping —
/// per-key dmabuf fds, in-flight frames (held until `Release`), and stable buffer ids.
struct EglBackend {
importer: EglImporter,
/// The dmabuf fd for each host key (`st_ino`), kept because the tiled path re-imports the fd
/// every frame (`eglCreateImage`) and the LINEAR path caches per fd inside the Vulkan bridge.
fds: HashMap<u64, OwnedFd>,
/// Insertion order of `fds` keys for the LRU cap.
fd_lru: VecDeque<u64>,
/// Frames delivered to the host and not yet released — holding the `DeviceBuffer` is what
/// keeps its device memory alive (pool `Arc`s) while the host encodes from it.
inflight: HashMap<u32, DeviceBuffer>,
/// Buffer id per device allocation. Valid only within one pool generation: pools never free
/// allocations while alive, so a device VA can't repeat until a size change replaces the pool
/// — at which point [`Self::note_dims`] clears this map (ids themselves are never reused;
/// `next_id` only counts up).
ids: HashMap<CUdeviceptr, u32>,
next_id: u32,
/// The (kind, width, height) of the last import — a change means the importer replaced its
/// pool, invalidating the VA→id map (see [`Self::ids`]).
last_shape: Option<(ImportKind, u32, u32)>,
}
impl EglBackend {
fn new(importer: EglImporter) -> EglBackend {
EglBackend {
importer,
fds: HashMap::new(),
fd_lru: VecDeque::new(),
inflight: HashMap::new(),
ids: HashMap::new(),
next_id: 0,
last_shape: None,
}
}
/// Store (or replace) the cached fd for `key`, evicting beyond the cap. A replaced or
/// evicted fd is first forgotten by the Vulkan bridge so its per-fd import can't go stale.
fn store_fd(&mut self, key: u64, fd: OwnedFd) {
if let Some(old) = self.fds.insert(key, fd) {
self.importer.forget_linear_fd(old.as_raw_fd());
self.fd_lru.retain(|k| *k != key);
}
self.fd_lru.push_back(key);
while self.fds.len() > FD_CACHE_CAP {
let Some(oldest) = self.fd_lru.pop_front() else {
break;
};
if let Some(old) = self.fds.remove(&oldest) {
self.importer.forget_linear_fd(old.as_raw_fd());
}
}
}
/// Clear the VA→id map when the importer is about to replace its per-size pool (see
/// [`Self::ids`]).
fn note_dims(&mut self, kind: ImportKind, width: u32, height: u32) {
if self.last_shape != Some((kind, width, height)) {
self.last_shape = Some((kind, width, height));
self.ids.clear();
}
}
}
impl ImportBackend for EglBackend {
fn modifiers(&mut self, fourcc: u32) -> Vec<u64> {
self.importer.supported_modifiers(fourcc)
}
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply {
if let Some(fd) = fd {
self.store_fd(req.key, fd);
} else if req.has_fd {
return Reply::Err {
message: "Import said has_fd but no fd arrived".into(),
};
}
let Some(raw) = self.fds.get(&req.key).map(|f| f.as_raw_fd()) else {
// We no longer hold this buffer's fd (LRU eviction / cache desync) — ask the host to
// resend it rather than failing the frame.
return Reply::NeedFd;
};
match self.import_inner(req, raw) {
Ok((id, desc)) => Reply::Frame { id, desc },
Err(e) => Reply::Err {
message: format!("{e:#}"),
},
}
}
fn release(&mut self, id: u32) {
if self.inflight.remove(&id).is_none() {
tracing::warn!(id, "release for a frame not in flight (host/worker desync)");
}
}
fn clear_cache(&mut self) {
for (_, fd) in self.fds.drain() {
self.importer.forget_linear_fd(fd.as_raw_fd());
}
self.fd_lru.clear();
self.importer.clear_linear_cache();
}
}
impl EglBackend {
/// The fallible core of [`ImportBackend::import`], once the fd for `req.key` is resolved.
fn import_inner(&mut self, req: &ImportReq, raw: i32) -> Result<(u32, Option<BufferDesc>)> {
let plane = DmabufPlane {
fd: raw,
offset: req.offset,
stride: req.stride,
};
self.note_dims(req.kind, req.width, req.height);
let buf = match req.kind {
ImportKind::Tiled => {
self.importer
.import(&plane, req.width, req.height, req.fourcc, req.modifier)?
}
ImportKind::TiledNv12 => self.importer.import_nv12(
&plane,
req.width,
req.height,
req.fourcc,
req.modifier,
)?,
ImportKind::Linear => self.importer.import_linear(&plane, req.width, req.height)?,
};
// Assign / look up the buffer's id and export its CUDA IPC identity on first delivery.
cuda::make_current()?;
let (id, desc) = match self.ids.get(&buf.ptr) {
Some(&id) => (id, None),
None => {
let id = self.next_id;
self.next_id = self.next_id.wrapping_add(1);
let y_handle = cuda::ipc_export(buf.ptr)?.to_vec();
let uv = match buf.uv {
Some((uv_ptr, uv_pitch)) => {
Some((cuda::ipc_export(uv_ptr)?.to_vec(), uv_pitch))
}
None => None,
};
self.ids.insert(buf.ptr, id);
(
id,
Some(BufferDesc {
width: buf.width,
height: buf.height,
y_handle,
y_pitch: buf.pitch,
uv,
}),
)
}
};
if self.inflight.insert(id, buf).is_some() {
// A pool never hands out a buffer that hasn't been recycled, so a duplicate id means
// corrupted bookkeeping — fail the import rather than alias two frames.
bail!("buffer id {id} already in flight");
}
Ok((id, desc))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
/// Records calls; import behavior is scripted per key.
struct MockBackend {
calls: mpsc::Sender<String>,
next: u32,
}
impl ImportBackend for MockBackend {
fn modifiers(&mut self, fourcc: u32) -> Vec<u64> {
let _ = self.calls.send(format!("modifiers:{fourcc}"));
vec![7, 8, 9]
}
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply {
let _ = self.calls.send(format!(
"import:key={} kind={:?} fd={}",
req.key,
req.kind,
fd.is_some()
));
if req.key == 0xbad {
return Reply::Err {
message: "scripted failure".into(),
};
}
if req.key == 0xfeed && !req.has_fd {
return Reply::NeedFd;
}
let id = self.next;
self.next += 1;
let desc = (id == 0).then(|| BufferDesc {
width: req.width,
height: req.height,
y_handle: vec![0u8; 64],
y_pitch: 256,
uv: None,
});
Reply::Frame { id, desc }
}
fn release(&mut self, id: u32) {
let _ = self.calls.send(format!("release:{id}"));
}
fn clear_cache(&mut self) {
let _ = self.calls.send("clear".into());
}
}
fn start_server() -> (
OwnedFd,
mpsc::Receiver<String>,
std::thread::JoinHandle<Result<()>>,
) {
let (host, worker) = proto::socketpair_seqpacket().unwrap();
let (tx, rx) = mpsc::channel();
let join = std::thread::spawn(move || {
let mut backend = MockBackend { calls: tx, next: 0 };
serve(&worker, &mut backend)
});
(host, rx, join)
}
fn import_req(key: u64, has_fd: bool) -> Request {
Request::Import {
key,
kind: ImportKind::Tiled,
width: 64,
height: 64,
fourcc: 1,
modifier: None,
offset: 0,
stride: 256,
has_fd,
}
}
#[test]
fn dispatch_and_eof() {
let (host, rx, join) = start_server();
let mut buf = Vec::new();
proto::send(host.as_fd(), &Request::Modifiers { fourcc: 42 }, None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(
reply,
Reply::Modifiers {
modifiers: vec![7, 8, 9]
}
);
// First import delivers the desc; the second (same mock id sequence continues) doesn't.
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
match reply {
Reply::Frame {
id: 0,
desc: Some(_),
} => {}
other => panic!("unexpected reply {other:?}"),
}
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(reply, Reply::Frame { id: 1, desc: None });
// A missing worker-side fd is a NeedFd reply (host resends), not a failure.
proto::send(host.as_fd(), &import_req(0xfeed, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(reply, Reply::NeedFd);
// A failed import is an Err reply, not a dead worker.
proto::send(host.as_fd(), &import_req(0xbad, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
match reply {
Reply::Err { message } => assert!(message.contains("scripted failure")),
other => panic!("unexpected reply {other:?}"),
}
// Fire-and-forget ops reach the backend without replies.
proto::send(host.as_fd(), &Request::Release { id: 0 }, None).unwrap();
proto::send(host.as_fd(), &Request::ClearCache, None).unwrap();
// Closing the host end terminates serve() cleanly.
drop(host);
join.join().unwrap().unwrap();
let calls: Vec<String> = rx.iter().collect();
assert_eq!(
calls,
vec![
"modifiers:42",
"import:key=1 kind=Tiled fd=false",
"import:key=1 kind=Tiled fd=false",
"import:key=65261 kind=Tiled fd=false", // 0xfeed
"import:key=2989 kind=Tiled fd=false", // 0xbad
"release:0",
"clear",
]
);
}
}
-5
View File
@@ -181,11 +181,6 @@ fn real_main() -> Result<()> {
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
#[cfg(target_os = "linux")]
Some("zerocopy-probe") => zerocopy::probe(),
// Hidden: the isolated GPU-import worker the capture path spawns from /proc/self/exe
// (design/zerocopy-worker-isolation.md) — never run by hand; --fd names the inherited
// socketpair end.
#[cfg(target_os = "linux")]
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
// on the GPU and compare against a BT.709 limited-range reference. Validates the Tier 2A
// `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.
-110
View File
@@ -161,8 +161,6 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(get_display_state))
.routes(routes!(release_display))
.routes(routes!(set_display_layout))
.routes(routes!(list_custom_presets, create_custom_preset))
.routes(routes!(update_custom_preset, delete_custom_preset))
.routes(routes!(get_status))
.routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients))
@@ -995,10 +993,6 @@ struct DisplaySettingsState {
effective: crate::vdisplay::policy::EffectivePolicy,
/// Every named preset and what it expands to (for the picker's preview).
presets: Vec<PresetInfo>,
/// The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered
/// alongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a
/// `Custom` policy carrying the preset's fields.
custom_presets: Vec<crate::vdisplay::policy::CustomPreset>,
/// Option names this build enforces right now. All five axes are now acted on (keep_alive +
/// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console
/// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout
@@ -1043,7 +1037,6 @@ fn display_settings_state() -> DisplaySettingsState {
settings,
configured,
presets,
custom_presets: policy::load_custom_presets(),
enforced: vec![
"keep_alive".into(),
"topology".into(),
@@ -1273,109 +1266,6 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
Json(display_settings_state()).into_response()
}
/// List the saved custom presets
///
/// The operator's named field-bundles (`display-presets.json`). These also ride the
/// `GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.
#[utoipa::path(
get,
path = "/display/presets",
tag = "display",
operation_id = "listCustomPresets",
responses(
(status = OK, description = "The saved custom presets", body = Vec<crate::vdisplay::policy::CustomPreset>),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn list_custom_presets() -> Json<Vec<crate::vdisplay::policy::CustomPreset>> {
Json(crate::vdisplay::policy::load_custom_presets())
}
/// Save a custom preset
///
/// Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can
/// apply later. The host assigns a stable id, returned in the body. Applying a preset is a
/// `PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.
#[utoipa::path(
post,
path = "/display/presets",
tag = "display",
operation_id = "createCustomPreset",
request_body = crate::vdisplay::policy::CustomPresetInput,
responses(
(status = CREATED, description = "Preset created", body = crate::vdisplay::policy::CustomPreset),
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
async fn create_custom_preset(
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
) -> Response {
if input.name.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
}
match crate::vdisplay::policy::add_custom_preset(input) {
Ok(preset) => (StatusCode::CREATED, Json(preset)).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Update a custom preset
#[utoipa::path(
put,
path = "/display/presets/{id}",
tag = "display",
operation_id = "updateCustomPreset",
params(("id" = String, Path, description = "The custom preset id")),
request_body = crate::vdisplay::policy::CustomPresetInput,
responses(
(status = OK, description = "Preset updated", body = crate::vdisplay::policy::CustomPreset),
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
async fn update_custom_preset(
Path(id): Path<String>,
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
) -> Response {
if input.name.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
}
match crate::vdisplay::policy::update_custom_preset(&id, input) {
Ok(Some(preset)) => Json(preset).into_response(),
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Delete a custom preset
///
/// Removes it from the catalog. The active policy is untouched — if this preset was the one applied,
/// the running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).
#[utoipa::path(
delete,
path = "/display/presets/{id}",
tag = "display",
operation_id = "deleteCustomPreset",
params(("id" = String, Path, description = "The custom preset id")),
responses(
(status = NO_CONTENT, description = "Preset deleted"),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
async fn delete_custom_preset(Path(id): Path<String>) -> Response {
match crate::vdisplay::policy::delete_custom_preset(&id) {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Live host status
#[utoipa::path(
get,
+13 -162
View File
@@ -26,9 +26,7 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::{
mtu1500_shard_payload, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
};
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
use punktfunk_core::quic::{
@@ -971,14 +969,11 @@ async fn serve_session(
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
max_data_per_block: 4096,
},
// The largest even payload whose sealed datagram (header + shard + crypto) fits an
// unfragmented IPv4/UDP packet on a 1500 MTU — 1408, giving 1472 = the exact ceiling.
// The previous 1452 overshot it (its math forgot the header/crypto ride inside the UDP
// payload) and silently IP-fragmented EVERY video datagram, doubling per-datagram loss
// on Wi-Fi — the "100 Mbps badly fails on the phone" root cause. Negotiated, so the
// client follows. Jumbo (≈8900) is a future negotiated bump (needs MAX_DATAGRAM_BYTES
// raised + end-to-end 9000 MTU).
shard_payload: mtu1500_shard_payload() as u16,
// ~1452-byte payload keeps the IP datagram within a 1500 MTU (1452 + 40 header + 24
// crypto + 8 IP/UDP ≈ 1500), vs the old 1200 — ~17% fewer packets for free, and an even
// size (FEC requires even shards). Negotiated, so the client follows. Jumbo (≈8900) is a
// future negotiated bump (needs MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
shard_payload: 1452,
encrypt: true,
key,
salt: *b"pkf1",
@@ -1097,18 +1092,8 @@ async fn serve_session(
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
// is pinned via PUNKTFUNK_FEC_PCT.
if adaptive_fec {
// Fast attack, slow decay: jump straight to what the reported loss
// needs, but come DOWN only one point per clean report (~750 ms). The
// memoryless controller ping-ponged on periodic burst loss (Wi-Fi
// scans / BT coexistence, a burst every few seconds): a single clean
// window dropped FEC back to the floor, so every next burst hit an
// unprotected stream — an unrecoverable frame, a freeze, and a
// recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps
// the stream covered across the gap while still converging to FEC_MIN
// on a genuinely clean link.
let prev = fec_target_ctl.load(Ordering::Relaxed);
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
fec_target_ctl.store(target, Ordering::Relaxed);
let target = adapt_fec(rep.loss_ppm);
let prev = fec_target_ctl.swap(target, Ordering::Relaxed);
if prev != target {
tracing::info!(
loss_ppm = rep.loss_ppm,
@@ -3111,7 +3096,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) =
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?;
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?;
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
#[cfg(target_os = "windows")]
drop(_idd_setup_guard);
@@ -3213,18 +3198,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
let mut cur_mode = mode;
const MAX_CAPTURE_REBUILDS: u32 = 5;
let mut capture_rebuilds: u32 = 0;
// Encode-stall watchdog: AMF/QSV (and async NVENC) poll non-blocking, so a wedged driver
// shows up as poll() returning None forever while submits keep succeeding — `inflight` grows,
// no AU ever reaches the send thread, and the client freezes on the last frame with nothing
// logged (field reports: AMD/Intel Windows streams freezing after minutes). Track when the
// encoder last produced an AU and rebuild it in place (bounded, like the capture rebuilds)
// when it stops. `ENCODE_STALL_WINDOW` also sizes the in-flight backlog bound: a backlog worth
// more than the window's frames means AUs still trickle (so the gap never trips) but latency
// is growing without bound — the slow-leak form of the same stall.
const ENCODE_STALL_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
const MAX_ENCODER_RESETS: u32 = 5;
let mut encoder_resets: u32 = 0;
let mut last_au_at = std::time::Instant::now();
// Last HDR mastering metadata we forwarded — re-sent as 0xCE on change/keyframe (see below).
let mut last_hdr_meta: Option<punktfunk_core::quic::HdrMeta> = None;
// Frames submitted to NVENC but not yet polled (wire pts, submit stamp, pacing deadline). With a
@@ -3295,7 +3268,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bit_depth,
plan,
&quit,
&stop,
)?;
Ok((new_vd, pipe))
})();
@@ -3311,11 +3283,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
vd = new_vd;
compositor = sw.compositor;
next = std::time::Instant::now();
// The owed AUs died with the old encoder — drop their in-flight records
// and restart the encode-stall clock for the fresh one.
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
tracing::info!(
compositor = compositor.id(),
"session switch — backend rebuilt, stream continues"
@@ -3350,11 +3317,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
(capturer, enc, frame, interval, cur_node_id) = next_pipe;
cur_mode = new_mode;
next = std::time::Instant::now();
// The owed AUs died with the old encoder — drop their in-flight records
// and restart the encode-stall clock for the fresh one.
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), ?new_mode,
@@ -3425,11 +3387,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// library instead of surfacing a failure — rather than the capture-loss rebuild + 40 s
// timeout. Gated to the dedicated bare-spawn launch (`launch_is_nested`), so a normal
// Bazzite/desktop capture loss still rebuilds in place.
// `cur_node_id` (the capture 5-tuple's node id) is read only by the Linux
// dedicated-game-exit check below; keep it read on other platforms so it isn't a
// write-only variable under `-D warnings` (the `let _ = &launch` idiom above).
#[cfg(not(target_os = "linux"))]
let _ = &cur_node_id;
#[cfg(target_os = "linux")]
if launch.is_some()
&& crate::vdisplay::launch_is_nested(compositor)
@@ -3502,7 +3459,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bit_depth,
plan,
&quit,
&stop,
) {
Ok(p) => break p,
Err(e2) => {
@@ -3524,12 +3480,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
cur_node_id = new_node_id;
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
next = std::time::Instant::now();
// The owed AUs died with the old encoder — drop their in-flight records and
// restart the encode-stall clock (the rebuild loop above may have eaten seconds,
// which must not count against the fresh encoder).
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
tracing::info!(
compositor = compositor.id(),
"capture loss: pipeline rebuilt — stream resumes"
@@ -3596,28 +3546,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
st_queue.push(queue_us);
}
let t_submit = std::time::Instant::now();
if let Err(e) = enc.submit(&frame) {
// The input half of an encode stall: once the driver stops draining AUs, libavcodec's
// one-frame buffer fills and avcodec_send_frame starts failing (EAGAIN) — the same
// wedge the watchdog below catches, seen from submit. Rebuild the encoder in place
// (bounded) instead of killing an otherwise healthy session; a backend without an
// in-place rebuild keeps today's fail-fast behavior.
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS
|| !reset_stalled_encoder(&mut enc, &mut inflight)
{
return Err(e).context("encoder submit");
}
tracing::error!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"encoder submit failed — encoder rebuilt in place, forcing an IDR");
last_au_at = std::time::Instant::now();
// Re-pace from the rebuild and retry this frame next tick (gives the fresh encoder
// one frame period to come up instead of hammering it in a hot loop).
next = std::time::Instant::now() + interval;
std::thread::sleep(interval);
continue;
}
enc.submit(&frame).context("encoder submit")?;
let submit_us = if measure {
t_submit.elapsed().as_micros() as u32
} else {
@@ -3635,12 +3564,9 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// so the encode of N overlaps the convert/copy of N+1. NVENC's `pending` is FIFO, so poll() returns
// the oldest submitted frame's AU — matching `inflight.pop_front()`.
let mut send_gone = false;
// A poll error is the explicit form of an encode stall (e.g. a QSV device failure);
// carry it to the shared stall recovery below instead of killing the session outright.
let mut poll_err: Option<anyhow::Error> = None;
while inflight.len() >= depth {
let t_wait = std::time::Instant::now();
let polled = enc.poll();
let polled = enc.poll().context("encoder poll")?;
let wait_us = if measure {
t_wait.elapsed().as_micros() as u32
} else {
@@ -3650,20 +3576,9 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
st_wait.push(wait_us);
}
let au = match polled {
Ok(Some(au)) => au,
// No AU ready for a submitted frame. Routine on the non-blocking backends (the
// libavcodec AMF/QSV wrapper holds ~2 frames; async NVENC drains a ready queue) —
// the frame stays in flight and the next tick re-polls. The stall watchdog below
// decides when "not ready yet" has become "the driver is wedged".
Ok(None) => break,
Err(e) => {
poll_err = Some(e);
break;
}
Some(au) => au,
None => break, // no AU ready for a submitted frame (shouldn't happen — poll blocks)
};
// The encoder is alive: feed the stall watchdog, clear the consecutive-reset counter.
last_au_at = std::time::Instant::now();
encoder_resets = 0;
let (cap_ns, sub_ns, deadline) = inflight.pop_front().expect("inflight non-empty");
let flags = if au.keyframe {
(FLAG_PIC | FLAG_SOF) as u32
@@ -3704,40 +3619,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
if send_gone {
break;
}
// Encode-stall watchdog. Trip on: an explicit poll error; no AU within the window while
// frames are owed (the full wedge — AMF/QSV's non-blocking poll returns None forever and
// nothing else ever errors); or an owed backlog worth more than the window's frames (the
// slow leak — AUs still trickle, so the gap never trips, but latency grows without bound).
// Recovery rebuilds the encoder in place and forces an IDR — a logged ~one-second hiccup
// instead of a silent permanent freeze — bounded so a genuinely dead encoder still ends
// the session with a clear error. The window scales with the frame interval so low-fps
// modes (where the AMF wrapper's ~2-frame hold spans seconds) can't false-trip.
let stall_window = ENCODE_STALL_WINDOW.max(interval * 8);
let stall_backlog =
depth + (stall_window.as_secs_f64() / interval.as_secs_f64().max(1e-6)).ceil() as usize;
if poll_err.is_some()
|| (!inflight.is_empty()
&& (last_au_at.elapsed() >= stall_window || inflight.len() > stall_backlog))
{
let why = match &poll_err {
Some(e) => format!("poll failed: {e:#}"),
None => format!(
"no AU for {} ms with {} frame(s) in flight",
last_au_at.elapsed().as_millis(),
inflight.len()
),
};
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS
|| !reset_stalled_encoder(&mut enc, &mut inflight)
{
return Err(poll_err.unwrap_or_else(|| anyhow!("{why}")))
.context("encoder stalled — in-place rebuild unavailable or exhausted");
}
tracing::error!(reset = encoder_resets, max = MAX_ENCODER_RESETS, %why,
"encode stall detected — encoder rebuilt in place, forcing an IDR");
last_au_at = std::time::Instant::now();
}
match next.checked_duration_since(std::time::Instant::now()) {
Some(d) => std::thread::sleep(d),
None => next = std::time::Instant::now(),
@@ -3810,7 +3691,6 @@ fn build_pipeline_with_retry(
bit_depth: u8,
plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
stop: &Arc<AtomicBool>,
) -> Result<Pipeline> {
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
@@ -3837,17 +3717,6 @@ fn build_pipeline_with_retry(
const MAX_ATTEMPTS: u32 = 8;
let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=MAX_ATTEMPTS {
// The client is gone (connection closed → `stop`): every further attempt only churns the
// box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and
// relaunching the box's Steam session once per attempt for minutes (the .181 storm
// 2026-07-07). One in-flight attempt can still overhang; this bounds the damage to it.
if attempt > 1 && stop.load(Ordering::SeqCst) {
anyhow::bail!(
"session ended (client disconnected) during pipeline build — aborting retries \
after {} attempt(s)",
attempt - 1
);
}
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) {
Ok(pipe) => {
if attempt > 1 {
@@ -3905,24 +3774,6 @@ fn is_permanent_build_error(chain: &str) -> bool {
PERMANENT.iter().any(|p| lower.contains(p))
}
/// Encode-stall recovery: rebuild the encoder in place (keeping capture + the session up) and
/// discard the owed in-flight frame records — their AUs died with the old encoder instance.
/// Returns `false` when the backend has no in-place rebuild ([`crate::encode::Encoder::reset`]'s
/// default); the caller then surfaces the stall as a session error instead. The forced keyframe
/// makes the rebuilt encoder's first frame an immediate decoder resync point (belt-and-suspenders:
/// a fresh encoder opens on an IDR anyway).
fn reset_stalled_encoder(
enc: &mut Box<dyn crate::encode::Encoder>,
inflight: &mut std::collections::VecDeque<(u64, u64, std::time::Instant)>,
) -> bool {
if !enc.reset() {
return false;
}
inflight.clear();
enc.request_keyframe();
true
}
fn build_pipeline(
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
mode: punktfunk_core::Mode,
+5
View File
@@ -619,6 +619,11 @@ pub fn apply_session_env(active: &ActiveSession) {
if let Some(d) = &e.xdg_current_desktop {
std::env::set_var("XDG_CURRENT_DESKTOP", d);
}
// Mutter on NVIDIA has no working dmabuf capture sync — force SHM there; the KWin/gamescope
// tiled/LINEAR paths keep zero-copy.
if active.kind == ActiveKind::DesktopGnome {
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
}
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
// [`effective_topology`] directly at create time — the console policy, else the legacy
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
@@ -824,46 +824,19 @@ fn kill_unit(unit: &str) {
.status();
}
/// Runtime-mask `unit` so the box's session supervisor cannot restart it underneath the takeover.
/// Bazzite/SteamOS autologin runs under SDDM with `Relogin=true` (`/etc/sddm.conf.d/steamos.conf`):
/// the moment the autologin session dies — including our own deliberate stop — SDDM logs back in and
/// starts the unit again within the same second. A merely-stopped unit then fights our host-managed
/// session over the Steam single instance and the GPU for the whole stream (the restarted wrapper
/// relaunches gamescope every ~7 s; the contention SIGSEGVs gamescopes and eventually kills the
/// streaming one — the "stream dies after 30 s5 min" field reports, diagnosed live on .181
/// 2026-07-07). `--runtime` keeps the mask in tmpfs so a reboot clears it even if the host dies
/// without restoring (the same semantics as the persisted takeover file).
fn mask_unit(unit: &str) {
let _ = Command::new("systemctl")
.args(["--user", "mask", "--runtime", unit])
.status();
}
/// Undo [`mask_unit`] — every restore path must unmask before (or regardless of) restarting, or the
/// box's own return-to-gaming-mode stays broken until reboot.
fn unmask_unit(unit: &str) {
let _ = Command::new("systemctl")
.args(["--user", "unmask", "--runtime", unit])
.status();
}
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// Stop every running autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// single-instance Steam is free for our own host-managed session. Records the units so
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
/// is autologged in (e.g. a box that boots headless). Each unit is **masked first** ([`mask_unit`]
/// SDDM's `Relogin=true` would otherwise restart it instantly), then torn down with **SIGKILL**
/// ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
/// Matches every loaded instance, not just `running` ones — under the SDDM relogin churn the unit
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the
/// fight the moment the supervisor restarts it.
/// is autologged in (e.g. a box that boots headless). Uses the **SIGKILL** teardown ([`kill_unit`])
/// to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
fn stop_autologin_sessions() {
let Ok(out) = Command::new("systemctl")
.args([
"--user",
"list-units",
"--type=service",
"--all",
"--state=running",
"--no-legend",
"--plain",
"gamescope-session-plus@*.service",
@@ -876,11 +849,10 @@ fn stop_autologin_sessions() {
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Some(unit) = line.split_whitespace().next() {
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
mask_unit(unit); // block the SDDM relogin loop from restarting it mid-stream
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
tracing::info!(
unit,
"freed Steam: masked + SIGKILL-stopped the autologin gaming session for this stream"
"freed Steam: SIGKILL-stopped the autologin gaming session for this stream"
);
stopped.push(unit.to_string());
}
@@ -1008,11 +980,6 @@ fn do_restore_tv_session() {
}
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
// Unmask UNCONDITIONALLY (before the desktop-active early return below): a unit left masked
// would break the user's own return to gaming mode until reboot.
for unit in &units {
unmask_unit(unit);
}
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back
@@ -1128,7 +1095,6 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
let wrapper = write_gamescope_bin_wrapper()?;
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
let hz = mode.refresh_hz.max(1);
let start_unit = || -> Result<()> {
let status = Command::new("systemd-run")
.args(["--user", "--collect", &format!("--unit={unit_name}")])
.arg("--setenv=BACKEND=headless")
@@ -1143,17 +1109,12 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
.arg(client)
.status()
.context(
"launch gamescope-session-plus via `systemd-run --user` (is the user systemd \
manager up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)",
"launch gamescope-session-plus via `systemd-run --user` (is the user systemd manager \
up with XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS set?)",
)?;
if !status.success() {
anyhow::bail!(
"`systemd-run --user` failed to start the gamescope session (exit {status})"
);
anyhow::bail!("`systemd-run --user` failed to start the gamescope session (exit {status})");
}
Ok(())
};
start_unit()?;
// Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s.
let deadline = Instant::now() + Duration::from_secs(45);
loop {
@@ -1167,45 +1128,10 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
(Steam failed to start? `journalctl --user -u {unit_name}`)"
);
}
// The session-plus wrapper hard-kills a gamescope that missed its 5 s readiness handshake
// and exits 1 (a slow NVIDIA cold start routinely needs 5-15 s — the .181 storm 2026-07-07),
// and the transient unit has no Restart= — without supervision the rest of this poll would
// wait on a corpse. Re-run the unit so every readiness attempt inside the deadline is used.
if !unit_starting_or_active(unit_name) {
tracing::info!(
unit = unit_name,
"gamescope session: transient unit died (missed the wrapper's 5 s gamescope \
readiness window?) relaunching"
);
// Brief cooldown before the relaunch: the wrapper SIGKILLed a gamescope mid-Vulkan-init,
// and the NVIDIA driver reclaims that context asynchronously — an instant relaunch pays
// the reclaim serialization on top of device init and misses the 5 s window again.
std::thread::sleep(Duration::from_millis(1500));
let _ = Command::new("systemctl")
.args(["--user", "reset-failed", unit_name])
.status();
start_unit()?;
}
std::thread::sleep(Duration::from_millis(500));
}
}
/// Is the unit currently starting or up (`activating` / `active` — also `deactivating`: let a stop
/// finish; the next poll tick sees the settled state)? Unknown/unreachable states report `true` so a
/// systemctl hiccup can't trigger a relaunch storm.
fn unit_starting_or_active(unit: &str) -> bool {
let Ok(out) = Command::new("systemctl")
.args(["--user", "is-active", unit])
.output()
else {
return true;
};
matches!(
String::from_utf8_lossy(&out.stdout).trim(),
"active" | "activating" | "reloading" | "deactivating"
)
}
/// Stop the host-managed session's transient unit ([`kill_unit`] — SIGKILL teardown to avoid the F44
/// GPU-context leak) and clear the EIS relay so a dead session's socket name can't be reconnected.
fn stop_session(unit_name: &str) {
@@ -223,35 +223,19 @@ impl VirtualDisplay for KwinDisplay {
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
fn reenable_outputs(outputs: &[(String, String)]) {
fn reenable_outputs(outputs: &[String]) {
if outputs.is_empty() {
return;
}
// Enable FIRST, as a standalone apply — a bare `output.X.enable` always succeeds, so a physical
// can never be left DARK. (Batching a possibly-stale `mode` arg into the same invocation risks
// kscreen-doctor rejecting the whole config and leaving the output disabled.)
let enable_args: Vec<String> = outputs
let args: Vec<String> = outputs
.iter()
.map(|(name, _)| format!("output.{name}.enable"))
.map(|o| format!("output.{o}.enable"))
.collect();
let _ = std::process::Command::new("kscreen-doctor")
.args(&enable_args)
.args(&args)
.status();
// THEN re-assert each captured mode, best-effort — a bare re-enable lets KWin fall back to the
// EDID-preferred mode (a 120 Hz panel returns at ~60 Hz); this restores the exact refresh. The
// output is enabled now, so the mode set is valid; a rejected mode just leaves KWin's default.
let mode_args: Vec<String> = outputs
.iter()
.filter(|(_, mode)| !mode.is_empty())
.map(|(name, mode)| format!("output.{name}.mode.{mode}"))
.collect();
if !mode_args.is_empty() {
let _ = std::process::Command::new("kscreen-doctor")
.args(&mode_args)
.status();
}
std::thread::sleep(Duration::from_millis(200));
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs (group empty)");
}
/// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by
@@ -343,39 +327,12 @@ fn read_active_refresh(output: &str) -> Option<u32> {
/// recognised by this prefix, so we never have to thread the live set through the backend.
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
/// The current mode of an output as a kscreen-doctor mode setter, from its `-j` entry — preferring
/// the human `WxH@Hz` form (survives a mode-id re-enumeration across disable→enable) and falling back
/// to the raw `currentModeId`. `None` if the current mode can't be resolved.
fn output_current_mode_spec(o: &serde_json::Value) -> Option<String> {
let as_id = |v: &serde_json::Value| -> Option<String> {
v.as_str()
.map(|s| s.to_string())
.or_else(|| v.as_u64().map(|n| n.to_string()))
};
let current = o.get("currentModeId").and_then(&as_id)?;
let mode = o
.get("modes")?
.as_array()?
.iter()
.find(|m| m.get("id").and_then(&as_id).as_deref() == Some(current.as_str()))?;
let human = (|| {
let size = mode.get("size")?;
let w = size.get("width").and_then(|v| v.as_u64())?;
let h = size.get("height").and_then(|v| v.as_u64())?;
let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?.round() as u64;
Some(format!("{w}x{h}@{hz}"))
})();
Some(human.unwrap_or(current))
}
/// Currently-ENABLED outputs that are **not managed by us** — the headless session's bootstrap
/// output(s) + any physical monitor, i.e. exactly what `exclusive` must disable — EACH PAIRED WITH ITS
/// CURRENT MODE (`WxH@Hz`, empty if unresolved) so teardown can put it back at that exact refresh (a
/// bare re-enable drops a 120 Hz panel to KWin's default ~60 Hz).
/// Names of currently-ENABLED outputs that are **not managed by us** — the headless session's
/// bootstrap output(s) + any physical monitor, i.e. exactly what `exclusive` must disable.
/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]).
fn other_enabled_outputs() -> Vec<(String, String)> {
fn other_enabled_outputs() -> Vec<String> {
let out = match std::process::Command::new("kscreen-doctor")
.arg("-j")
.output()
@@ -392,15 +349,9 @@ fn other_enabled_outputs() -> Vec<(String, String)> {
.map(|outs| {
outs.iter()
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
.filter_map(|o| {
let name = o.get("name").and_then(|n| n.as_str())?;
(!name.starts_with(MANAGED_PREFIX)).then(|| {
(
name.to_string(),
output_current_mode_spec(o).unwrap_or_default(),
)
})
})
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.filter(|n| !n.starts_with(MANAGED_PREFIX))
.map(String::from)
.collect()
})
.unwrap_or_default()
@@ -441,7 +392,7 @@ fn a_managed_output_is_primary() -> bool {
/// the sole desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
/// showing only the wallpaper) rather than failing the session.
fn apply_virtual_primary(name: &str) -> Vec<(String, String)> {
fn apply_virtual_primary(name: &str) -> Vec<String> {
let ours = format!("Virtual-{name}");
let kscreen = |args: &[String]| {
std::process::Command::new("kscreen-doctor")
@@ -464,12 +415,11 @@ fn apply_virtual_primary(name: &str) -> Vec<(String, String)> {
}
// Disable everything still enabled that ISN'T a managed group member (bootstrap / physical), so
// the group is unambiguously the desktop — never a sibling session's output (group-aware filter).
// Each is captured WITH its current mode so teardown restores its real refresh, not KWin's default.
let others = other_enabled_outputs();
if !others.is_empty() {
let args: Vec<String> = others
.iter()
.map(|(o, _mode)| format!("output.{o}.disable"))
.map(|o| format!("output.{o}.disable"))
.collect();
let _ = kscreen(&args);
}
@@ -412,8 +412,8 @@ fn mode_flag(md: &DbusMode, key: &str) -> bool {
matches!(md.6.get(key).map(|v| &**v), Some(&Value::Bool(true)))
}
/// The current (else preferred, else first) mode of `connector` → `(mode_id, width, height, refresh)`.
fn current_mode_full(state: &CurrentState, connector: &str) -> Option<(String, i32, i32, f64)> {
/// The current (else preferred, else first) mode of `connector` → (mode_id, width, height).
fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i32)> {
let mon = state.1.iter().find(|m| m.0 .0 == connector)?;
let pick = mon
.1
@@ -421,83 +421,7 @@ fn current_mode_full(state: &CurrentState, connector: &str) -> Option<(String, i
.find(|md| mode_flag(md, "is-current"))
.or_else(|| mon.1.iter().find(|md| mode_flag(md, "is-preferred")))
.or_else(|| mon.1.first())?;
Some((pick.0.clone(), pick.1, pick.2, pick.3))
}
/// As [`current_mode_full`] but dropping the refresh (callers that only place by width).
fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i32)> {
current_mode_full(state, connector).map(|(id, w, h, _)| (id, w, h))
}
/// Pure mode-pick for a KEPT physical (unit-tested). Given the physical's PRE-connect mode
/// (`pre_mode = (id, w, h, refresh)`; `None` when the connector is new since the snapshot) and the
/// mode list Mutter reports for it in the POST-virtual state
/// (`(id, w, h, refresh, is_current, is_preferred)`), return the `(mode_id, width)` to re-apply.
///
/// Mutter re-derives its layout when the `RecordVirtual` output appears and can silently drop a
/// 120 Hz panel to its EDID-preferred 60 Hz — so the post-virtual `is-current` is *already* 60 Hz.
/// We therefore prefer the PRE mode (its real refresh), resolved to a mode id valid at apply time;
/// only when the physical genuinely no longer offers that mode do we fall back to the post-virtual
/// current (never inventing a mode id `ApplyMonitorsConfig` would reject).
fn pick_keep_mode(
pre_mode: Option<(String, i32, i32, f64)>,
state_modes: &[(String, i32, i32, f64, bool, bool)],
) -> Option<(String, i32)> {
let state_current = || {
state_modes
.iter()
.find(|m| m.4)
.or_else(|| state_modes.iter().find(|m| m.5))
.or_else(|| state_modes.first())
.map(|m| (m.0.clone(), m.1))
};
let Some((pre_id, w, h, hz)) = pre_mode else {
return state_current();
};
// The exact pre mode id, if the connector still offers it (same session ⇒ usually true).
if state_modes.iter().any(|m| m.0 == pre_id) {
return Some((pre_id, w));
}
// Else a re-keyed id with the same geometry + refresh (still the real 120 Hz).
if let Some(m) = state_modes
.iter()
.find(|m| m.1 == w && m.2 == h && (m.3 - hz).abs() < 0.5)
{
return Some((m.0.clone(), m.1));
}
// The physical genuinely no longer offers that mode — use whatever is valid now.
state_current()
}
/// The `(mode_id, width)` a kept physical should be RE-APPLIED at — its PRE-connect mode preserved
/// across Mutter's virtual-output layout re-derive. See [`pick_keep_mode`].
fn physical_keep_mode(
pre: &CurrentState,
state: &CurrentState,
conn: &str,
) -> Option<(String, i32)> {
let pre_mode = current_mode_full(pre, conn);
let state_modes: Vec<(String, i32, i32, f64, bool, bool)> = state
.1
.iter()
.find(|m| m.0 .0 == conn)
.map(|mon| {
mon.1
.iter()
.map(|md| {
(
md.0.clone(),
md.1,
md.2,
md.3,
mode_flag(md, "is-current"),
mode_flag(md, "is-preferred"),
)
})
.collect()
})
.unwrap_or_default();
pick_keep_mode(pre_mode, &state_modes)
Some((pick.0.clone(), pick.1, pick.2))
}
/// Wait for the virtual output to appear in DisplayConfig (its size follows PipeWire negotiation,
@@ -541,7 +465,7 @@ async fn make_virtual_primary(
let config = if exclusive {
build_exclusive_config(&vconn, &vmode)
} else {
build_primary_keeping_physicals(pre, &state, &vconn, &vmode, mode.width as i32)
build_primary_keeping_physicals(&state, &vconn, &vmode, mode.width as i32)
};
let _: () = dc
.call(
@@ -581,20 +505,13 @@ fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
}
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its **pre-connect**
/// mode). So the shell + new windows land on the streamed surface, but the operator's physical
/// screen stays on **at its real refresh**. On a headless host (no physicals) this is identical to
/// [`build_exclusive_config`].
///
/// `pre` is the snapshot taken *before* the virtual output existed (physical still at its true
/// refresh); `state` is the post-virtual state. We read each physical's mode from `pre` because
/// Mutter can knock a 120 Hz panel down to 60 Hz when it re-derives the layout for the virtual
/// monitor — reading `state` would cement that 60 Hz (`physical_keep_mode`).
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its current mode). So
/// the shell + new windows land on the streamed surface, but the operator's physical screen stays
/// on. On a headless host (no physicals) this is identical to [`build_exclusive_config`].
///
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
fn build_primary_keeping_physicals(
pre: &CurrentState,
state: &CurrentState,
vconn: &str,
vmode: &str,
@@ -608,15 +525,15 @@ fn build_primary_keeping_physicals(
true,
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
)];
// Append each physical (non-virtual) connector that has a usable mode, to the right of the
// virtual output, as a non-primary secondary — at its PRE-connect mode (real refresh preserved).
// Append each physical (non-virtual) connector that has a usable current mode, to the right of
// the virtual output, as a non-primary secondary.
let mut x = virt_width.max(0);
for mon in &state.1 {
let conn = &mon.0 .0;
if conn == vconn {
continue;
}
if let Some((mode_id, w)) = physical_keep_mode(pre, state, conn) {
if let Some((mode_id, w, _h)) = current_mode(state, conn) {
logicals.push((
x,
0,
@@ -630,84 +547,3 @@ fn build_primary_keeping_physicals(
}
logicals
}
#[cfg(test)]
mod tests {
use super::pick_keep_mode;
// (id, w, h, refresh, is_current, is_preferred)
fn m(
id: &str,
w: i32,
h: i32,
hz: f64,
cur: bool,
pref: bool,
) -> (String, i32, i32, f64, bool, bool) {
(id.to_string(), w, h, hz, cur, pref)
}
#[test]
fn keep_mode_prefers_pre_refresh_over_downgraded_state() {
// Physical was 2560x1440@120 pre-connect; after the virtual appeared Mutter marked 60 Hz
// current (the reported bug). We must re-apply the 120 Hz mode, not the state's 60 Hz.
let pre = Some(("M120".to_string(), 2560, 1440, 120.0));
let state = vec![
m("M120", 2560, 1440, 120.0, false, false),
m("M60", 2560, 1440, 60.0, true, true),
];
assert_eq!(
pick_keep_mode(pre, &state),
Some(("M120".to_string(), 2560))
);
}
#[test]
fn keep_mode_rekeyed_id_matches_by_geometry_and_refresh() {
// The pre id is no longer offered (Mutter re-keyed the mode list), but a 120 Hz mode of the
// same geometry exists — match it so the real refresh survives.
let pre = Some(("old-120".to_string(), 2560, 1440, 120.0));
let state = vec![
m("new-120", 2560, 1440, 119.998, false, false),
m("new-60", 2560, 1440, 60.0, true, true),
];
assert_eq!(
pick_keep_mode(pre, &state),
Some(("new-120".to_string(), 2560))
);
}
#[test]
fn keep_mode_falls_back_to_state_current_when_pre_mode_gone() {
// The physical genuinely no longer offers its pre mode (e.g. cable renegotiated to a lower
// max) — never invent an id; use the post-virtual current.
let pre = Some(("gone-165".to_string(), 3440, 1440, 165.0));
let state = vec![
m("s-100", 3440, 1440, 100.0, true, false),
m("s-60", 3440, 1440, 60.0, false, true),
];
assert_eq!(
pick_keep_mode(pre, &state),
Some(("s-100".to_string(), 3440))
);
}
#[test]
fn keep_mode_no_pre_uses_state_current_then_preferred() {
// A connector new since the pre-snapshot (no pre mode): is-current wins, else is-preferred.
let state = vec![
m("A", 1920, 1080, 60.0, true, false),
m("B", 1920, 1080, 144.0, false, true),
];
assert_eq!(pick_keep_mode(None, &state), Some(("A".to_string(), 1920)));
let no_current = vec![
m("A", 1920, 1080, 60.0, false, false),
m("B", 1920, 1080, 144.0, false, true),
];
assert_eq!(
pick_keep_mode(None, &no_current),
Some(("B".to_string(), 1920))
);
}
}
+1 -155
View File
@@ -23,11 +23,10 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::Duration;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use utoipa::ToSchema;
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
@@ -459,163 +458,10 @@ pub fn prefs() -> &'static DisplayPolicyStore {
})
}
// ---------------------------------------------------------------------------------------
// User-defined custom presets (`<config>/display-presets.json`)
// ---------------------------------------------------------------------------------------
/// A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a
/// built-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names
/// and applies from the console.
///
/// Unlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in
/// `<config>/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these
/// fields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and
/// the built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:
/// editing or deleting a preset never mutates the running policy (re-apply to adopt a change).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct CustomPreset {
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
pub id: String,
/// User-facing name shown on the preset card; editable.
pub name: String,
/// The six display-behavior axes this preset applies (the same shape a built-in preset expands to).
pub fields: EffectivePolicy,
/// The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).
/// A custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying
/// one does set this axis.
#[serde(default)]
pub game_session: GameSession,
}
/// Request body to create or replace a custom preset (no `id` — the host owns it).
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct CustomPresetInput {
pub name: String,
pub fields: EffectivePolicy,
#[serde(default)]
pub game_session: GameSession,
}
fn custom_presets_path() -> PathBuf {
crate::gamestream::config_dir().join("display-presets.json")
}
/// Clamp a saved preset's fields to their valid ranges — the same bounds [`DisplayPolicy::sanitized`]
/// enforces, so a preset can never carry an out-of-range `max_displays` that a later apply would reject.
fn sanitize_preset_fields(mut fields: EffectivePolicy) -> EffectivePolicy {
fields.max_displays = fields.max_displays.clamp(1, 16);
fields
}
/// Load the saved custom presets (empty + non-fatal if the file is absent or malformed — a bad
/// catalog never breaks the console's settings GET).
pub fn load_custom_presets() -> Vec<CustomPreset> {
match std::fs::read(custom_presets_path()) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!(error = %e, "display-presets.json malformed — ignoring custom presets");
Vec::new()
}),
Err(_) => Vec::new(),
}
}
/// Persist the catalog (private dir, temp-write + atomic rename — the [`DisplayPolicyStore::set`]
/// discipline, so a crash mid-write never truncates it).
fn save_custom_presets(presets: &[CustomPreset]) -> Result<()> {
let path = custom_presets_path();
if let Some(dir) = path.parent() {
crate::gamestream::create_private_dir(dir)?;
}
let tmp = path.with_extension("json.tmp");
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(presets)?)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
/// 12 hex chars from the name + wall-clock nanos — collision-free in practice, no uuid dep (the
/// [`crate::library`] custom-entry id scheme).
fn new_preset_id(name: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
hex::encode(&Sha256::digest(format!("{name}:{nanos}").as_bytes())[..6])
}
/// Create a custom preset, returning it with its assigned id.
pub fn add_custom_preset(input: CustomPresetInput) -> Result<CustomPreset> {
let mut presets = load_custom_presets();
let preset = CustomPreset {
id: new_preset_id(&input.name),
name: input.name,
fields: sanitize_preset_fields(input.fields),
game_session: input.game_session,
};
presets.push(preset.clone());
save_custom_presets(&presets)?;
Ok(preset)
}
/// Replace a custom preset's fields (id preserved). `None` ⇒ no preset with that id.
pub fn update_custom_preset(id: &str, input: CustomPresetInput) -> Result<Option<CustomPreset>> {
let mut presets = load_custom_presets();
let Some(slot) = presets.iter_mut().find(|p| p.id == id) else {
return Ok(None);
};
slot.name = input.name;
slot.fields = sanitize_preset_fields(input.fields);
slot.game_session = input.game_session;
let updated = slot.clone();
save_custom_presets(&presets)?;
Ok(Some(updated))
}
/// Delete a custom preset. `false` ⇒ no preset with that id.
pub fn delete_custom_preset(id: &str) -> Result<bool> {
let mut presets = load_custom_presets();
let before = presets.len();
presets.retain(|p| p.id != id);
if presets.len() == before {
return Ok(false);
}
save_custom_presets(&presets)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_preset_serde_roundtrips_and_defaults_game_session() {
let preset = CustomPreset {
id: "abc123".into(),
name: "My Rig".into(),
fields: preset_fields(Preset::GamingRig).unwrap(),
game_session: GameSession::Dedicated,
};
let json = serde_json::to_string(&preset).unwrap();
assert_eq!(serde_json::from_str::<CustomPreset>(&json).unwrap(), preset);
// A catalog written before `game_session` existed still loads (defaults to `Auto`).
let legacy: CustomPreset = serde_json::from_value(serde_json::json!({
"id": "x",
"name": "Legacy",
"fields": serde_json::to_value(preset_fields(Preset::Default).unwrap()).unwrap(),
}))
.unwrap();
assert_eq!(legacy.game_session, GameSession::Auto);
}
#[test]
fn sanitize_preset_fields_clamps_max_displays() {
let mut f = preset_fields(Preset::Default).unwrap();
f.max_displays = 999;
assert_eq!(sanitize_preset_fields(f.clone()).max_displays, 16);
f.max_displays = 0;
assert_eq!(sanitize_preset_fields(f).max_displays, 1);
}
#[test]
fn keep_alive_serializes_tagged_on_mode() {
assert_eq!(
@@ -33,8 +33,8 @@ use windows::Win32::System::Threading::{
use super::{DisplayOwnership, Mode, VirtualOutput};
use crate::win_display::{
count_other_active, force_extend_topology, isolate_displays_ccd, resolve_gdi_name,
restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig,
force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd,
set_active_mode, set_virtual_primary_ccd, SavedConfig,
};
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
@@ -673,32 +673,16 @@ impl VirtualDisplayManager {
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
}
Topology::Primary => {
// On a headless box the IDD auto-activates as the SOLE display, so a physical
// (if present) is deactivated and QueryDisplayConfig sees only the virtual —
// force EXTEND to (re)activate every connected display alongside the virtual,
// THEN reposition to make the virtual primary. BUT on a box whose physical is
// ALREADY active (the IDD came up extended beside it — the common desktop case),
// that physical is already lit at its real mode; re-applying the bare
// `SDC_TOPOLOGY_EXTEND` preset would only re-pull each display's mode from the
// persistence DB, RESETTING a 120 Hz panel to 60 Hz. So force-EXTEND only when the
// virtual is currently sole; otherwise skip straight to the reposition, which
// re-supplies each physical's QUERIED mode verbatim (preserving its refresh).
// SAFETY: `count_other_active` runs the CCD QueryDisplayConfig FFI (Copy target id
// by value, owned result), under the `state` lock.
let already_extended =
unsafe { count_other_active(added.target_id) }.unwrap_or(0) > 0;
if already_extended {
tracing::info!(
"display topology=primary — a physical display is already active; \
skipping force-EXTEND (preserves its refresh) before making the \
virtual primary"
);
} else {
// SAFETY: `force_extend_topology` drives the CCD topology FFI (no args, no
// borrowed memory), under the `state` lock — the sole topology mutator.
// The IDD auto-activates as the SOLE display on a headless box, so the
// physical (if present) is deactivated and QueryDisplayConfig sees only the
// virtual. Force EXTEND first to (re)activate every CONNECTED display
// alongside the virtual, THEN reposition to make the virtual primary — so the
// physical stays active. (The bring-up above only force-EXTENDs when the
// virtual FAILS to auto-resolve; here it resolved, so we do it explicitly.)
// SAFETY: `force_extend_topology` drives the CCD topology FFI (no args, no borrowed
// memory), under the `state` lock — the sole topology mutator.
unsafe { force_extend_topology() };
thread::sleep(Duration::from_millis(300));
}
// SAFETY: `set_virtual_primary_ccd` takes the `Copy` target id by value and returns
// an owned `SavedConfig` (no borrowed memory crosses), under the `state` lock.
ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) };
@@ -384,10 +384,8 @@ unsafe fn query_active_config() -> Option<SavedConfig> {
}
/// Count currently-ACTIVE display paths whose target id != `keep_target_id` — i.e. displays that would
/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually
/// took, and (in the `primary` topology) to detect a physical that is ALREADY active so we can skip a
/// force-EXTEND that would reset its refresh.
pub(crate) unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually took.
unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
let (paths, _) = query_active_config()?;
Some(
paths
-287
View File
@@ -1,287 +0,0 @@
# Native AMF encoder — handoff design
> **Status: PHASES 1 + 2 + 3 IMPLEMENTED (2026-07-06).** `encode/windows/amf.rs` ships the
> direct-SDK encoder per §3 — FFI pinned to AMF headers v1.4.36, bounded poll, native `reset()`.
> Phase 2: **AV1** (open-time probe gate; per-codec enum divergences honored — AV1 swaps the
> ULL/LL usage values and uses GOP=0 + FORCE_FRAME_TYPE_KEY=1), **intra-refresh**
> (`PUNKTFUNK_INTRA_REFRESH` opt-in mirroring Linux NVENC; `caps().intra_refresh` reflects the
> driver's actual acceptance), **in-band HDR mastering/CLL metadata** (`*InHDRMetadata` host
> buffer; HEVC + AV1), and the **native codec probe**. Phase 3: **the ffmpeg-AMF dispatch
> fallback + `PUNKTFUNK_AMF_FFMPEG` hatch are deleted** — AMD dispatch / codec advertisement /
> 4:4:4 answer are native-only; FFmpeg serves QSV only (`ffmpeg_win.rs`'s AMF machinery is kept
> solely as the A/B comparator). `windows_backend_is_ffmpeg``windows_backend_is_probed`. **The
> §7 field-silence gate on Phase 3 was pre-empted on explicit direction** — see the §7 gate note
> for what that costs (VP-format-fallback now fails the session; AMFVideoConverter is the owed
> native fix).
> Live-validated on the lab Ryzen iGPU (VCN 3): AVC + HEVC batches across a native in-place reset
> (Annex-B IDR contract, FIFO pairing); HEVC Main10 P010 with the mastering + CLL prefix SEIs
> **confirmed present in the encoded IDR**; intra-refresh property accepted on both codecs; probe
> honestly answers h264/h265=true, av1=false on this RDNA2 part. The **§5.2 latency A/B is
> measured** (`amf_latency_ab_bench`, 1080p60 HEVC): native `encode_us` p50 **5.18 ms (0.31 frame
> periods)** vs libavcodec-AMF **16.9 ms (1.01)** — 3.3× lower, the frame-hold gone. Owed: §5.3
> on-glass session behaviors + soak (macOS-client on-glass test in progress), and field
> validation on ≥2 VCN generations (AV1/RDNA3 has no lab hardware).
> Companion context: the encode-stall watchdog + `Encoder::reset()` (punktfunk1.rs / encode.rs,
> shipped 2026-07-06) and the QSV backend module docs in `encode/windows/ffmpeg_win.rs`.
## 1. Why (measured, not speculative)
Three independent reasons, in order of weight:
1. **The libavcodec AMF wrapper's structural ~2-frame output hold.** `hevc_amf`/`av1_amf`
need frame N+2 submitted before they release frame N's AU. Measured on the Ryzen 7000
iGPU (VCN, 720p60): encode→retrieve **36 ms p50, dead-stable**, invariant across pipeline
depth 1/2, every `usage` preset, and any spin budget (a 150 ms poll spin provably never
produced the owed AU — it pegged at exactly 150 ms). See the `poll` doc comment in
`ffmpeg_win.rs`. The direct-SDK NVENC path retrieves in ~12 ms. At 60 Hz this is ~33 ms
of pure pipeline latency no FFmpeg-side knob can remove; at 120 Hz it is two whole frame
budgets.
2. **Silent driver wedges surface as forever-EAGAIN, not errors.** The field failure
(AMD/Intel streams freezing after ~35 min) was invisible because the wrapper's only
"not ready" signal is EAGAIN, indistinguishable from a healthy pipeline warming up. The
2026-07-06 watchdog converts that into a bounded rebuild + IDR, but it is a safety net
with a ~2 s detection floor. The AMF runtime itself returns typed `AMF_RESULT` codes
(`AMF_INPUT_FULL`, device-lost, etc.) — a native path sees the wedge on the frame it
happens.
3. **Feature gaps libavcodec cannot express.** No intra-refresh wave (every
FEC-unrecoverable loss is answered with a full IDR — the 2040× frame-size spike the
Linux NVENC intra-refresh mode exists to avoid), no in-band HDR mastering SEI
(`EncoderCaps::supports_hdr_metadata` is NVENC-only today), coarse per-frame control.
## 2. The decision: drop FFmpeg for AMF, keep it for QSV
**Native AMF replaces the libavcodec AMF path (phased, §7). FFmpeg stays for QSV.**
- QSV via libavcodec with `async_depth=1` + `low_power` VDEnc is already near the hardware
latency floor; a direct libvpl port would buy little for its cost. Revisit only if Intel
field data shows a QSV-specific gap (separate doc if so).
- Because QSV stays on FFmpeg, the FFmpeg DLLs keep shipping and the `amf-qsv` build
feature keeps existing. Dropping FFmpeg *entirely* is therefore not on the table here —
"drop" means: the AMF dispatch stops going through it once the native path is validated.
- During bring-up the ffmpeg-AMF path remains as an automatic open-failure fallback and an
explicit escape hatch (§7), then its AMF dispatch is deleted in Phase 3. Two permanently
maintained AMF paths would double the driver-matrix burden, and the one we'd keep "for
safety" is precisely the one with the wedge/latency pathology.
## 3. Architecture
New module `crates/punktfunk-host/src/encode/windows/amf.rs` implementing
`crate::encode::Encoder`, compiled unconditionally on Windows (**no new build-time
dependency and no new cargo feature**): the AMF runtime is loaded at runtime from the
driver-installed `amfrt64.dll`, exactly as `nvenc.rs` loads `nvEncodeAPI64.dll`
(`load_api`). A box without an AMD driver simply fails the open and the dispatch falls
through. In-tree FFI decl module (`amf_sys` submodule or `#[repr(C)]` blocks in-file,
mirroring the small interface subset we use) — model it on how `ffmpeg_win.rs` mirrors
`AVD3D11VADeviceContext` rather than pulling a binding crate (none is maintained).
### 3.1 FFI strategy (the load-bearing detail)
The AMF public headers (GPUOpen `AMF/amf/public/include`) define **C-compatible vtable
structs** for every interface (`AMFFactoryVtbl`, `AMFContextVtbl`, `AMFComponentVtbl`,
`AMFSurfaceVtbl`, `AMFDataVtbl`, `AMFBufferVtbl`, `AMFVariantStruct`, …) — this is not a
guess: FFmpeg's `amfenc.c` is plain C and drives AMF exclusively through those vtables, so
the C ABI is the stable, supported surface. Mirror only what we call:
- Entry points: `GetProcAddress("AMFQueryVersion")` (gate: runtime ≥ the pinned
`AMF_FULL_VERSION` we mirror headers from) and `GetProcAddress("AMFInit")`
`AMFFactory*`.
- `factory->CreateContext``context->InitDX11(capturer_device, AMF_DX11_1)` — **the
capturer's own `ID3D11Device`**, same-device requirement as every other backend (the
capture textures are not shared-handle; see the `ensure_inner_d3d11` rebind logic in
`ffmpeg_win.rs` for the device-change lifecycle to replicate).
- `factory->CreateComponent(context, name)` with `AMFVideoEncoderVCE_AVC` /
`AMFVideoEncoder_HEVC` / `AMFVideoEncoder_AV1` → `encoder->Init(AMF_SURFACE_NV12|P010,
w, h)`.
- Per-frame: `context->CreateSurfaceFromDX11Native(texture, &surface, observer)`
per-surface properties (pts via `SetPts`, forced-IDR picture type) →
`encoder->SubmitInput(surface)`; retrieve via `encoder->QueryOutput(&data)`
(`AMF_REPEAT` = not ready), `AMFBuffer::GetNative/GetSize``EncodedFrame`.
- Every mirrored struct/call carries a `// SAFETY:` proof — the whole encode module tree is
under `#![deny(clippy::undocumented_unsafe_blocks)]` (unsafe-proof program).
Reference implementations to crib from (read both before writing FFI): FFmpeg `amfenc.c`
(the C vtbl usage, property plumbing, result-code handling) and OBS
`plugins/obs-ffmpeg/texture-amf.cpp` (D3D11 texture submission + low-latency streaming
config, C++ but the call sequence is what matters). **Verify every property name against
the pinned SDK headers** — names below are from those references and must not be trusted
blind.
### 3.2 Input path (zero-copy by construction)
Own a small D3D11 texture ring (NV12 or P010, `D3D11_BIND_RENDER_TARGET |
SHADER_RESOURCE`, size = `pipeline_depth + 2`), `CopySubresourceRegion` the captured
texture into the next slot (GPU-local, same pattern as `ZeroCopyInner::submit`), wrap the
slot with `CreateSurfaceFromDX11Native`, submit. The copy decouples the encoder from the
capturer's rotating IDD ring; do NOT wrap the capturer's texture directly. This makes
`PUNKTFUNK_ZEROCOPY` irrelevant for native AMF — there is no readback path to fall back
to. Handle the capturer's video-processor format fallback (`Bgra`/`Rgb10a2` instead of
NV12/P010 — see `pool_mismatch` in `ffmpeg_win.rs`) by returning an open/submit error in
Phase 1 so dispatch falls back to the ffmpeg path; an AMFVideoConverter front-end is a
Phase 2 option if that fallback ever fires in the field. `FramePayload::Cpu` (DDA without
video processor): same treatment — ffmpeg fallback in Phase 1.
### 3.3 Retrieval model
Bounded-blocking poll, the `vaapi.rs::poll` model: after `SubmitInput`, spin
`QueryOutput` with ~250 µs sleeps up to a budget of `min(3/4 frame interval, 12 ms)`; on
expiry return `Ok(None)` (the session loop keeps the frame in flight and the watchdog
arbitrates wedges). VCN encode at streaming settings is ~15 ms, so the AU ships the same
tick — this is where the ~2-frame hold dies. Expected observable — measure **`encode_us`
(submit→AU, in `FrameMsg`/the web-console stats), not `wait_us`**: on the ffmpeg path the
hold hides in `encode_us` (~2 frame periods) because its non-blocking poll returns EAGAIN
in ~2 µs; on the native bounded poll the ASIC wait becomes visible as a few ms of
`wait_us` while `encode_us` collapses to ~1 frame period or less.
### 3.4 Encoder configuration (initial property set)
Mirror the intent of the ffmpeg opts block in `open_win_encoder` (`ffmpeg_win.rs:216-247`).
AVC names given; HEVC/AV1 have `_HEVC_`/`_AV1_` twins — check headers:
| Intent | AMF property (verify!) |
| --- | --- |
| usage preset (keep `PUNKTFUNK_AMF_USAGE` mapping) | `AMF_VIDEO_ENCODER_USAGE` = `ULTRA_LOW_LATENCY` (default) |
| CBR, target==peak | `RATE_CONTROL_METHOD=CBR`, `TARGET_BITRATE`, `PEAK_BITRATE` |
| 1-frame VBV (keep `PUNKTFUNK_VBV_FRAMES`) | `VBV_BUFFER_SIZE` |
| HRD + no filler | `ENFORCE_HRD=true`, `FILLER_DATA_ENABLE=false` |
| latency-first quality | `QUALITY_PRESET=SPEED` |
| no B-frames (AVC) | `B_PIC_PATTERN=0` |
| infinite GOP | `IDR_PERIOD=0` (HEVC: `GOP_SIZE`/`NUM_GOPS_PER_IDR` — check) |
| low-latency submission | `LOWLATENCY_MODE=true` (newer SDKs) |
| in-band VPS/SPS/PPS on IDR (wire contract: `EncodedFrame` doc) | HEVC `HEADER_INSERTION_MODE=IDR_ALIGNED`; AVC `HEADER_INSERTION_SPACING` — check |
| SDR/HDR VUI | `FULL_RANGE_COLOR=false` + color primaries/transfer/matrix props (BT.709 vs BT.2020-PQ, mirroring `open_win_encoder`) |
| 10-bit | `COLOR_BIT_DEPTH=10` + P010 surfaces |
| per-frame forced IDR | on the input surface: `AMF_VIDEO_ENCODER_FORCE_PICTURE_TYPE=IDR` |
| intra-refresh wave (Phase 2) | AVC `INTRA_REFRESH_NUM_MBS_PER_SLOT`; HEVC CTB twin — check |
| HDR mastering SEI (Phase 2) | HEVC `INPUT_HDR_METADATA` (`AMFHDRMetadata` buffer) |
`SetProperty` failures on *optional* properties (LOWLATENCY_MODE, intra-refresh) must be
log-and-continue, not fatal — availability varies by VCN generation/driver.
### 3.5 Error + stall semantics (interplay with the 2026-07-06 watchdog)
- `SubmitInput``AMF_INPUT_FULL`: **back-pressure, NOT a wedge — drain and retry, do not
reset.** (Original prescription "return `Err` → in-place reset" was **wrong**, disproven
on-glass 2026-07-06: at throughput-ceiling loads — 5120x1440@240 P010 on the lab iGPU —
`INPUT_FULL` → reset → forced IDR → a bigger keyframe → worse overload → a ~320 ms
reset/IDR cascade, strictly worse than the libavcodec path's 16-deep input queue riding it
out as latency. The log showed dozens of `submit failed … AMF_INPUT_FULL … rebuilt in place`
and **zero** watchdog stalls.) The shipped handling: `submit` bounds in-flight surfaces below
the input ring depth (`pending.len() < RING`) by draining finished AUs (buffered in a `ready`
deque for `poll`, FIFO-preserved) to free a slot *before* reusing it, and treats a stray
`INPUT_FULL` from `SubmitInput` the same way (drain + retry the surface). Only a drain that
makes NO progress for a bounded budget (`INPUT_DRAIN_BUDGET`, 200 ms — well under the
session watchdog's ~2 s) is a genuine wedge that escalates to `Err` → the in-place reset. This
also closed a **latent corruption**: the old path let in-flight grow to AMF's internal input
queue limit (16) against a ring of 4, so surfaces referenced ring slots already overwritten —
the reset masked it. Any other non-OK `SubmitInput` result: `Err`.
- `QueryOutput``AMF_REPEAT`: keep spinning within the poll budget, then `Ok(None)`.
`AMF_EOF`: `Ok(None)` after flush. Anything else: `Err` (the loop's poll-error path
resets).
- Implement `Encoder::reset()` natively: `encoder->Drain/Flush`, `Terminate()`, re-`Init`
on the same context (fall back to full context teardown if re-Init fails). Cheaper and
more targeted than the ffmpeg path's drop-and-lazily-reopen.
- `caps()`: `supports_rfi: false` (AMF has no NVENC-style reference invalidation —
intra-refresh is the substitute), `intra_refresh: true` once Phase 2 lands (this flag is
what makes the session glue rate-limit client keyframe requests — see the `IDR_WINDOW`
logic in punktfunk1.rs), `supports_hdr_metadata: true` once the SEI lands,
`chroma_444: false` **permanently** (VCN hardware does not encode 4:4:4;
`probe_can_encode_444` stays false — this is not an FFmpeg limitation).
### 3.6 Encoder trait contract (do not break)
From `encode.rs` + the punktfunk1 loop: AUs must come out FIFO in submit order
(`inflight.pop_front()` pairs with poll order); `data` is Annex-B with in-band headers on
IDRs (both a playable ES and self-contained wire AUs); `poll` returning `Ok(None)` is
legal and watchdog-arbitrated; `submit` must never block indefinitely; after `flush()`,
`poll` drains remaining AUs then returns `Ok(None)`; single encode thread owns the
encoder (manual `unsafe impl Send` with the same proof shape as `FfmpegWinEncoder`).
## 4. Integration seams (exact)
- `encode.rs::open_video_backend`, `WindowsBackend::Amf` arm: try `amf::AmfEncoder::open`
first; on `Err`, `tracing::warn!` + fall back to `ffmpeg_win` (when the `amf-qsv`
feature is built) — the same graceful-degrade shape as zero-copy→system today. Escape
hatch: `PUNKTFUNK_AMF_FFMPEG=1` skips the native path (field triage). Phase 3 deletes
both the fallback arm and the hatch.
- `resolved_backend_label` / `crate::gpu` session record: new label `"amf"` stays (the
mgmt API shows the same name; add `"amf-ffmpeg"` only if the fallback fires, so field
logs distinguish the paths).
- `probe_can_encode` (GameStream codec advertisement) and `windows_codec_support`: replace
the ffmpeg open-probe with a native factory probe (`CreateComponent` per codec on the
selected adapter) once Phase 2 lands; cache shape stays.
- `can_encode_444`: unchanged (`false`).
- The encode-stall watchdog and `Encoder::reset` (punktfunk1.rs): unchanged — it remains
the backstop for in-FFI hangs the native path can't self-detect.
## 5. Validation plan (this box has an AMD iGPU — use it)
Baseline first, on the ffmpeg path (already deployed 2026-07-06 with the watchdog): a
long session on the iGPU with `PUNKTFUNK_PERF=1`, record `wait_us_p50/p99`,
`encode_us`, client-measured latency, and whether the watchdog ever fires. Then per phase:
1. Open/probe smoke per codec (AVC, HEVC, HEVC-10) on the iGPU. **DONE** — the gated live
tests in `amf.rs` (`amf_encode_live_smoke` AVC+HEVC+AV1-probe, `amf_hdr_encode_live_smoke`,
`amf_native_probe_live`, `amf_intra_refresh_property_live`) pass on the lab Ryzen iGPU
(VCN3/RDNA2): both codecs across a native `reset()`, HEVC Main10 IDR carrying the
mastering(137)+CLL(144) SEIs byte-verified, intra-refresh property accepted, probe honestly
`h264/h265=true, av1=false`.
2. A/B the encode latency: expect `encode_us` p50 ~2 frame periods → ≤ 1 frame period
(see §3.3 for why `wait_us` is the wrong metric on the ffmpeg side). **MEASURED**
2026-07-06 by the gated `amf_latency_ab_bench` (`PUNKTFUNK_AMF_BENCH=1`, 1080p60 HEVC,
180 paced frames, same D3D11 NV12 input to both encoders, lab iGPU, debug build):
native `encode_us` p50 **5.18 ms (0.31 frame periods)** / p99 5.81 ms vs libavcodec-AMF
p50 **16.9 ms (1.01 frame periods)** / p99 17.5 ms — **3.3× lower, ~11.7 ms/frame saved**,
and the native path is decisively sub-frame (the ~2-frame hold that used to live in
`encode_us` is gone). Note the ffmpeg baseline came in at ~1 frame period, not the ~2 this
plan projected: the shipping ffmpeg config already sets AMF `latency=true` (a ~1-frame
hold), so the realized win is 3.3× / ~12 ms rather than the ~30 ms projected against an
un-tuned 2-frame baseline; direction and sub-frame collapse are exactly as §3.3 described.
Release builds should show a lower native number still (debug charges host-side
surface-create + copy-submit into the 5.18 ms). Zero-copy baseline for the input side
already measured 2026-07-06 on the lab iGPU (1080p120 HDR P010): `submit_us` p50 2.72.9 ms
(system readback) → **0.26 ms** (zero-copy D3D11), p99 6.6 ms → 0.5 ms.
3. Behavior: IDR on connect; mode switch mid-stream; HDR session (PQ VUI + 0xCE
convergence); client keyframe-request recovery; encoder `reset()` under an injected
failure; ≥30 min soak for the freeze class (watchdog log line
`encode stall detected` must NOT appear).
4. Driver matrix beyond the lab box is field data: VCN1 (Raven) through VCN4/5 differ in
preset support — the optional-property tolerance in §3.4 is what absorbs this.
## 6. Risks / open questions
- **Vtable mirroring correctness** is the concentrated risk: pin one AMF header version in
a comment, mirror minimally, and unit-test `AMFQueryVersion`/`AMFInit` + a headless
`CreateComponent` probe (skips cleanly on non-AMD boxes, like the NVENC live-gated
tests).
- Per-frame `CreateSurfaceFromDX11Native` allocation churn — if it shows up in profiles,
AMF supports pre-created surface pools; start simple.
- AV1 is RDNA3+; probe, never assume (same rule as everywhere in this codebase).
- Hybrid boxes: context must init on the *selected* adapter's device (the capture
device) — inherited for free by taking the capturer's device, but test with the
web-console GPU preference pointed at each GPU.
- The AMF runtime ships with the AMD driver, not with us — a missing/ancient `amfrt64.dll`
must produce a clean "install/update the AMD driver" error at open, then fall back
(Phase 1) or fail the session with that message (Phase 3).
## 7. Phasing
| Phase | Scope | Exit criterion | Status |
| --- | --- | --- | --- |
| 1 | FFI layer + AVC/HEVC (SDR + 10-bit HDR), bounded poll, native `reset()`, dispatch with ffmpeg fallback + `PUNKTFUNK_AMF_FFMPEG` hatch | §5.25.3 pass on the lab iGPU | **DONE** 2026-07-06 (§5.2 measured; §5.3 on-glass in progress) |
| 2 | Intra-refresh (`caps().intra_refresh`), in-band HDR SEI (`supports_hdr_metadata`), AV1, native codec probe | field-validated on ≥2 VCN generations | **CODE DONE** 2026-07-06 (lab VCN3 only; AV1/RDNA3 + 2nd VCN gen still owed) |
| 3 | Delete the ffmpeg-AMF dispatch arm + hatch; FFmpeg remains QSV-only | one release of field silence on the fallback label | **DONE** 2026-07-06 — see the gate note below |
**Phase 3 gate note (honesty):** the stated exit criterion (one release of field silence on the
fallback label) was **NOT met** — Phase 3 was cut the same day the native path was written, on
explicit direction, alongside a live macOS-client on-glass test. What Phase 3 removed: the
`WindowsBackend::Amf` libavcodec fallback arm, the `PUNKTFUNK_AMF_FFMPEG` hatch, and the
AMF→ffmpeg routes in `windows_codec_support` / `can_encode_444`. AMD dispatch, codec
advertisement, and the 4:4:4 answer are all native-only now; FFmpeg (`ffmpeg_win.rs`) is reached
only for QSV in production (its `WinVendor::Amf` machinery is retained solely as the
`amf_latency_ab_bench` comparator, not deleted — excising it would churn the Intel-unvalidated
QSV code for no gain). **Residual risk this pre-emption carries:** with the ffmpeg readback path
gone, an AMD box whose capturer can't produce video-processor NV12/P010 (falls back to
Bgra/Rgb10a2, or hands DDA CPU frames) now **fails the session** instead of degrading — the
design's answer is the native AMFVideoConverter front-end (§3.2), owed if that fallback is ever
seen in the field. Not observed on lab hardware (the VP yields NV12/P010). Reverting Phase 3 is a
small, localized diff if field data turns up trouble.
+1 -10
View File
@@ -1,15 +1,6 @@
# Zero-copy capture hardening — issue handoff
> **Status: FIXED + validated (2026-07-06).** The fix is implemented and on-glass validated — see
> [`zerocopy-worker-isolation.md`](zerocopy-worker-isolation.md): the GPU import (tiled EGL/GL→CUDA
> *and* LINEAR Vulkan→CUDA) now runs in a per-capture **worker subprocess** (CUDA-IPC frame
> hand-off), so this driver SIGSEGV kills the worker and the host degrades to its capture-loss
> rebuild; plus in-process teardown-order fixes and a poison/latch path replacing the corrupt
> tiled→CPU fallback. Validated on the RTX 5070 Ti/GNOME box: worker path streams at p50 1.30 ms,
> and a `kill -9` of the worker mid-stream is survived + recovered (fresh worker in ~185 ms,
> streaming resumes). The description below is kept as the issue record.
>
> *(Original handoff intro:)* This document describes a reproduced
> **Status: HANDOFF — issue description only (2026-07-06).** This document describes a reproduced
> host **SIGSEGV** in the Linux zero-copy capture path. It deliberately does **not** prescribe a fix —
> the next agent plans the implementation. Everything below is observed fact + root-cause analysis;
> the "Considerations / open questions" section frames the solution space without committing to one.
-163
View File
@@ -1,163 +0,0 @@
# Zero-copy capture hardening — GPU-import worker isolation
> **Status: IMPLEMENTED + on-glass validated (2026-07-06).** This is the implementation
> plan + decision record for the crash described in
> [`zerocopy-hardening-handoff.md`](zerocopy-hardening-handoff.md) (host SIGSEGV inside
> `libnvidia-eglcore` via `cuGraphicsMapResources` when the compositor invalidated an imported
> dmabuf mid-map, observed on the Bazzite F44 Game→Desktop switch). Validated on the RTX 5070 Ti /
> GNOME box (.21): the isolated worker carries frames at **p50 1.30 ms** end-to-end (NV12, 1800
> frames 0-mismatched), and a `kill -9` of the worker mid-stream is survived by the host and
> recovered — poison → `capture lost — rebuilding pipeline in place` → a fresh worker in **~185 ms**
> → streaming resumes (2385 frames, 0 mismatched, one 33 ms blip at the rebuild seam). See §6.
## 1. The decision: isolate, don't (only) prevent
The handoff's §9 framed two directions — *prevent the stale resource* vs *isolate the crash*.
The audit (§3 below) shows our per-frame lifetime discipline is already correct: the `EGLImage`
is created and destroyed strictly inside the PipeWire `on_process` callback while the buffer is
held (not requeued), and the CUDA-registered textures are **our own GL render targets**, never
wrappers around producer buffers. The invalidation that crashed the host is **external**
a compositor crash (or GPU channel wreckage from the surrounding plasmashell/Xwayland core dumps)
yanked the dmabuf's GPU-side state while the driver executed our in-flight GL sampling + CUDA map.
No in-process ordering fix can close that race, and a driver SIGSEGV is not catchable.
So the fix is **process isolation**: the entire `EglImporter` (tiled dmabuf → EGL/GL → CUDA *and*
LINEAR dmabuf → Vulkan bridge → CUDA) moves into a small per-capture **worker subprocess**. If the
driver faults, the *worker* dies; the host observes a dead socket, fails the frame/capture cleanly,
and the existing capture-loss rebuild path (`gamestream/stream.rs`, `punktfunk1.rs`) takes over —
which is exactly what already happens today on the safe SHM path when a compositor goes away.
What is deliberately **not** isolated:
- **SHM/CPU capture** — no GPU import, nothing to contain.
- **VAAPI passthrough** (AMD/Intel) — capture only `dup`s the dmabuf fd; the GPU import happens in
the encoder (Mesa VA, which reports errors rather than faulting; no observed crashes). Out of
scope here.
- **NVENC itself** — libavcodec/NVENC surface errors as return codes; if the GPU is globally
wedged the encoder errors and the session rebuilds. Isolating encode would mean shipping a
session-wide media-pipeline process, far beyond this fix.
## 2. Architecture
```
host process worker process (punktfunk-host zerocopy-worker)
──────────── ───────────────────────────────────────────────
PipeWire on_process EGLDisplay + GL ctx + CUDA ctx + VkBridge
│ dmabuf fd (held, fence-waited) │
├── IMPORT{key,geometry} + fd ──────────────▶│ eglCreateImage → GL blit/NV12 convert
│ (SCM_RIGHTS, first sight per key) │ → cuGraphicsMapResources → copy → unmap
│ │ → pooled CUDA buffer (cuMemAllocPitch)
│◀────────── FRAME{id [, ipc desc]} ─────────┤ exported ONCE via cuIpcGetMemHandle
│ host opens the IPC handle once, │
│ wraps it as DeviceBuffer │
▼ │
encode thread (NVENC) reads the device ptr │ keeps the DeviceBuffer in-flight
│ DeviceBuffer drop │
└── RELEASE{id} ────────────────────────────▶│ returns the buffer to its pool
```
- **Transport**: a `socketpair(AF_UNIX, SOCK_SEQPACKET)` created before spawn; the child end is
`dup2`'d to fd 3 (`zerocopy-worker --fd 3`). SEQPACKET gives reliable, ordered, message-framed
delivery; dmabuf fds ride as `SCM_RIGHTS`. Messages are small serde_json bodies (~200 B/frame;
negligible at 240 fps).
- **Frame data never crosses the socket.** The worker's `BufferPool` allocations are exported once
each via `cuIpcGetMemHandle`; the host `cuIpcOpenMemHandle`s each exactly once (cached by buffer
id) and reuses the mapping as the pool recycles. Per frame the reply is just `{id}` — the copy
was already synced (`copy_blocking`) worker-side before the reply, so the host reads complete
pixels. The result is the same zero-CPU-touch path as before, plus one socket RTT (~tens of µs).
- **fd caching**: the host keys each PipeWire buffer by its dmabuf `st_ino` (unique per dma-buf
object) and sends the fd only on first sight; the worker keeps the received dup (tiled: for the
per-frame `eglCreateImage`; LINEAR: for the Vulkan `src_cache`). A format renegotiation
(`param_changed`) sends `CLEAR_CACHE`, dropping both sides' caches — this also fixes the
pre-existing LINEAR-path bug where `VkBridge::src_cache` was keyed by raw fd number and never
invalidated across pool recycles (§3, trigger b). Cache desync is self-healing: a worker that no
longer holds a key's fd (LRU eviction) answers `NeedFd` and the host retries once with the fd.
- **Lifetimes**: the worker holds each exported frame as a real `DeviceBuffer` in an in-flight map
until `RELEASE{id}` arrives, so the existing pool `Arc` machinery keeps device memory alive
across pool replacement while the host still reads it. Host-side, every remote `DeviceBuffer`
holds an `Arc` of the client's shared state (socket + IPC-mapping cache), so mappings are closed
only after the last in-flight frame drops.
- **Worker lifetime**: one worker per capture (per `pipewire_thread`), spawned from
`/proc/self/exe`. It exits on socket EOF; the host reaps children via a global sweep list (no
zombies). Host death ⇒ EOF ⇒ worker exit.
### Failure semantics (the point of the exercise)
| event | behavior |
|---|---|
| worker init fails (no GPU, EGL error) | handshake reports `init_err` → capture falls back to the CPU/SHM offer, same as `EglImporter::new()` failure today |
| driver SIGSEGV in the worker (the observed crash) | socket EOF → import fails with a *dead-worker* error → the capturer is **poisoned**`next_frame`/`try_latest` return an error → the session's capture-loss rebuild runs (new capturer, new worker). **The host process survives.** |
| tiled import fails but worker alive (e.g. `EGL_BAD_MATCH` on one frame) | frame dropped; after 3 consecutive failures the capturer poisons → rebuild. It must **never** fall through to the CPU mmap path — mmap of a *tiled* dmabuf de-pads scrambled bytes (a pre-existing fallback bug; the CPU fallback was only ever correct for LINEAR). |
| LINEAR import fails | unchanged: fall back to the CPU mmap path in-stream (a LINEAR dmabuf is mappable), degraded not dead |
| repeated worker deaths | a process-wide latch (`note_gpu_import_death`, 3 consecutive deaths without a successful import between them) disables the GPU importer for the rest of the process — rebuilds renegotiate the SHM offer. Stops a wedged GPU stack from crash-looping the worker while still streaming (CPU path). A successful import resets the streak. |
### Escape hatch
`PUNKTFUNK_ZEROCOPY_INPROC=1` keeps the importer in-process (the pre-isolation behavior) for
debugging and A/B latency comparison. Default is the worker.
## 3. Audit answers for handoff §5 (which triggers are actually reachable)
- **Compositor crash / restart** — reachable (observed). Contained by the worker.
- **PipeWire buffer-pool recycle / renegotiation**:
- *Tiled EGL path*: **not reachable in code** — the `EGLImage` lives strictly inside
`on_process` while the buffer is held; the CUDA registrations wrap our own persistent GL
textures, not producer buffers.
- *LINEAR Vulkan path*: **reachable**`VkBridge::src_cache` keyed by raw fd, never
invalidated: a pool teardown + fd-number reuse could serve a stale imported buffer (wrong
frame or driver fault), and old entries leaked. Fixed by st_ino keys + `CLEAR_CACHE` on
renegotiation + an LRU cap.
- **Virtual-output teardown / mode change racing an in-flight map** — same class as compositor
crash (external invalidation, another thread); contained by the worker.
- **Output removal** — ditto.
## 4. In-process lifetime fixes (also shipped, they harden the worker itself)
- `Nv12Blit::drop` deleted its GL textures **before** the struct fields dropped, i.e. while
`y_tex`/`uv_tex` were still CUDA-registered. Now `RegisteredTexture::release()` runs first
(unregister → delete), removing a driver-state hazard of exactly the class that crashed.
- `GlBlit` had **no** `Drop` — its GL program/VAO/FBO/textures leaked on every size change and on
importer teardown. Now mirrors `Nv12Blit` (release registrations, then delete GL objects).
## 5. Residual risks, accepted
- A worker death while the encode thread still holds an IPC-mapped frame: the exporting process is
gone; the host-side mapping stays open until the `DeviceBuffer` drops. CUDA surfaces this as a
copy error at worst (encode error → session rebuild), not a host fault.
- The VAAPI encoder's in-host VA dmabuf import (Mesa) keeps its current exposure; no NVIDIA-class
faults observed there.
- `cuIpcOpenMemHandle` requires same-device, different-process — both hold by construction.
## 6. Validation
- **GPU-less (CI / dev VM)**: protocol unit tests (framing, fd round-trip over a socketpair,
error propagation, dead-worker detection against a mock server, latch behavior); worker-spawn
failure path (spawning a non-worker exe ⇒ clean fallback).
- **On-glass (NVIDIA RTX 5070 Ti + GNOME/Mutter, .21, 2026-07-06)** — steps 12 **PASSED**:
1. streamed `PUNKTFUNK_ZEROCOPY=1` through the worker (`zerocopy import worker ready`
`zero-copy GPU import isolated in a worker process``dmabuf imported to CUDA … nv12=true`),
end-to-end **p50 1.30 ms** (1800 frames, 0 mismatched) — parity with the pre-isolation path;
2. `kill -9` the worker mid-stream → host **survived**; the next import logged
`tiled GPU import lost — failing this capture for rebuild … Broken pipe … dead=true`, then
`capture lost — rebuilding pipeline in place, rebuild=1`, a **fresh worker (new pid) in
~185 ms**, and streaming resumed (2385 frames, 0 mismatched; single 33 ms frame at the seam).
The `worker-ready` count was 2 (original + rebuild), confirming the respawn.
Still pending: 3. a real compositor kill/restart mid-stream on a KWin box (the exact original
trigger — a `kill -9` of the worker is a strictly harsher event, so this is corroboration not a
gap); 4. `nv12-selftest` (in-process path untouched). *Note: on a static virtual desktop the
dead-worker detection only fires once a new frame triggers an import — realistic (a running game
produces continuous frames) but it means an idle desktop can sit poisoned-but-quiet briefly.*
## 7. Files
- `crates/punktfunk-host/src/linux/zerocopy/proto.rs` — message types + SEQPACKET/SCM_RIGHTS I/O.
- `crates/punktfunk-host/src/linux/zerocopy/worker.rs` — worker main loop (`zerocopy-worker`),
backend trait (testable), EGL/CUDA backend.
- `crates/punktfunk-host/src/linux/zerocopy/client.rs``RemoteImporter` (spawn, handshake, IPC
mapping cache, release plumbing, reaping) + the `Importer` enum (Remote | InProc).
- `crates/punktfunk-host/src/linux/zerocopy/cuda.rs` — CUDA IPC entry points; remote-release
`DeviceBuffer`s.
- `crates/punktfunk-host/src/linux/zerocopy/egl.rs` — teardown-order fixes (§4).
- `crates/punktfunk-host/src/capture/linux/mod.rs``Importer` wiring, tiled-failure poisoning,
death latch, `CLEAR_CACHE` on renegotiation.
- `crates/punktfunk-host/src/main.rs` — the hidden `zerocopy-worker` subcommand.
+2 -2
View File
@@ -85,13 +85,13 @@ cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
The template is deliberately minimal — it does **not** force a compositor, because the host
auto-detects Gaming Mode (gamescope) vs Desktop (KWin) on every connect and follows the switch
mid-stream. The only settings that matter are the session anchors (GPU zero-copy is on by default):
mid-stream. The only settings that matter are the session anchors plus zero-copy:
```sh
XDG_RUNTIME_DIR=/run/user/1000
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
PUNKTFUNK_ZEROCOPY=1 # GPU zero-copy (dmabuf → CUDA → NVENC); auto-falls back to CPU
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session (see below)
```
+1 -1
View File
@@ -36,7 +36,7 @@ On Linux the host **rewrites `WAYLAND_DISPLAY` / `XDG_CURRENT_DESKTOP` / `XDG_RU
|---|---|---|
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` (aliases: `kde`/`plasma`, `gnome`, `sway`/`hyprland`) | Which backend creates the virtual display. **Leave unset to auto-detect;** set only to force one. |
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). Leave on; it falls back to a CPU path automatically. |
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots. Auto-detected with the compositor. |
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
+1 -1
View File
@@ -21,7 +21,7 @@ WAYLAND_DISPLAY=wayland-0
XDG_CURRENT_DESKTOP=GNOME
PUNKTFUNK_COMPOSITOR=mutter
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
PUNKTFUNK_ZEROCOPY=1
PUNKTFUNK_INPUT_BACKEND=libei
```
+1 -1
View File
@@ -20,7 +20,7 @@ WAYLAND_DISPLAY=wayland-0
XDG_CURRENT_DESKTOP=KDE
PUNKTFUNK_COMPOSITOR=kwin
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
PUNKTFUNK_ZEROCOPY=1
PUNKTFUNK_INPUT_BACKEND=libei
```
+1 -1
View File
@@ -27,7 +27,7 @@ these in `~/.config/punktfunk/host.env`:
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, hyprland
PUNKTFUNK_INPUT_BACKEND=wlr
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
PUNKTFUNK_ZEROCOPY=1 # GPU zero-copy capture→encode; auto-falls back to CPU
```
See [Configuration](/docs/configuration) for the full reference.
@@ -38,24 +38,6 @@ the individual options documented further down.
| **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. |
| **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. |
## Save your own preset
The five above are curated starting points. When you've dialed in a setup you like — whether by
picking a preset and tweaking it or by setting every option under **Custom** — you can **save it as
your own named preset** and switch back to it in one click later.
- **Save as preset** — names the settings currently in force (all of the options below **plus**
*Dedicated game sessions*) and adds it to the picker alongside the built-ins.
- **Apply** — selecting a saved preset writes exactly those settings, the same as picking a built-in.
- **Edit / delete** — rename a saved preset, update it to your current settings, or remove it. Deleting
a preset never changes what's running — it only takes the card out of the picker.
Unlike the built-in presets (which deliberately leave *Dedicated game sessions* alone so switching
presets never changes your game-launch routing), a **custom preset captures your full setup**,
including that axis — because it's *your* saved configuration, not a curated behavior bundle. Custom
presets live on the host in `display-presets.json` (next to `display-settings.json`); the catalog and
the active policy are independent, so editing a preset never disturbs a running session.
## Options reference
Choose **Custom** in the console to set these directly.
-12
View File
@@ -1501,18 +1501,6 @@ PunktfunkStatus punktfunk_connection_probe_result(const PunktfunkConnection *c,
PunktfunkProbeResult *out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Signal a **deliberate quit** (a user "stop", not a network drop) before closing: the connection
// closes with [`QUIT_CLOSE_CODE`] instead of code 0, so the host tears the session down immediately
// (skips the keep-alive linger) rather than holding it for a reconnect. Call this right before
// [`punktfunk_connection_close`] on a user-initiated disconnect; a plain close (network drop,
// backgrounding) leaves the linger intact. NULL is a no-op.
//
// # Safety
// `c` was returned by [`punktfunk_connect`] and remains valid (closed via `punktfunk_connection_close`).
void punktfunk_connection_disconnect_quit(PunktfunkConnection *c);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
//
+5 -6
View File
@@ -242,9 +242,8 @@ PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui
# gamescope hosts its own EIS input socket — input lands in the nested session.
PUNKTFUNK_INPUT_BACKEND=gamescope
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC) is ON by default and auto-falls back to CPU if
# unavailable. No need to set it. Set to 0 only to force the CPU path.
# PUNKTFUNK_ZEROCOPY=0
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC). Auto-falls back to CPU if unavailable.
PUNKTFUNK_ZEROCOPY=1
#RUST_LOG=info
```
@@ -258,7 +257,7 @@ PUNKTFUNK_INPUT_BACKEND=gamescope
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` | Create a per-client virtual output at the client's exact WxH@Hz (the flagship "native resolution, no scaling" mode), vs. `portal` which captures an existing monitor. |
| `PUNKTFUNK_GAMESCOPE_APP` | `steam -gamepadui` | The command launched **inside** the nested gamescope — here, a SteamOS-style couch UI. Set it to whatever you want the session to run. |
| `PUNKTFUNK_INPUT_BACKEND` | `gamescope` | Inject mouse/keyboard/gamepad into the nested gamescope via its own EIS socket. |
| `PUNKTFUNK_ZEROCOPY` | `on` *(default)* | GPU zero-copy capture (dmabuf → CUDA → NVENC), on by default. Falls back to CPU automatically if unavailable; set `0` to force the CPU path. |
| `PUNKTFUNK_ZEROCOPY` | `1` | GPU zero-copy capture (dmabuf → CUDA → NVENC). Falls back to CPU automatically if unavailable. |
| `RUST_LOG` | (commented) | Uncomment `RUST_LOG=info` for verbose logs while debugging. |
**Optional — a real DualSense for clients holding one:** add `PUNKTFUNK_GAMEPAD=dualsense` to present
@@ -464,8 +463,8 @@ desktop viewer.
after an `rpm-ostree`/`bootc` update, confirm the NVIDIA driver still loads (`nvidia-smi`) before
blaming punktfunk.
- **Zero-copy falls back to CPU.** The zero-copy path (on by default) needs working EGL/CUDA from the
NVIDIA driver. The code falls back to CPU automatically; check the log for the fallback line and
- **`PUNKTFUNK_ZEROCOPY=1` but it falls back to CPU.** The zero-copy path needs working EGL/CUDA from
the NVIDIA driver. The code falls back to CPU automatically; check the log for the fallback line and
verify the `-nvidia` image / driver is healthy.
- **Wrong UID in `host.env`.** `XDG_RUNTIME_DIR=/run/user/1000` and the bus path assume UID 1000. Run
+2 -3
View File
@@ -10,9 +10,8 @@ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC) is ON by default and auto-falls back to CPU if
# unavailable. No need to set it. Set to 0 only to force the CPU path.
# PUNKTFUNK_ZEROCOPY=0
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC). Auto-falls back to CPU if unavailable.
PUNKTFUNK_ZEROCOPY=1
#RUST_LOG=info
+1 -1
View File
@@ -8,7 +8,7 @@ WAYLAND_DISPLAY=wayland-kde
XDG_CURRENT_DESKTOP=KDE
PUNKTFUNK_COMPOSITOR=kwin
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
PUNKTFUNK_ZEROCOPY=1
PUNKTFUNK_INPUT_BACKEND=libei
# UDP Generic Segmentation Offload on the send path: coalesce a frame's equal-size packets into
# kernel super-buffers (one sendmsg per ~64 packets instead of one per packet) — the dominant
+2 -2
View File
@@ -13,8 +13,8 @@
# bash scripts/headless/run-headless-kde.sh [WxH] # default 1920x1080
#
# Then in another shell:
# WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \
# punktfunk-host punktfunk1-host --source virtual --seconds 14400 # zero-copy is on by default
# WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_ZEROCOPY=1 \
# punktfunk-host punktfunk1-host --source virtual --seconds 14400
set -euo pipefail
RES="${1:-1920x1080}"
+2 -3
View File
@@ -16,9 +16,8 @@ XDG_CURRENT_DESKTOP=KDE
# resolution+refresh (the flagship mode); `portal` captures an existing monitor.
PUNKTFUNK_VIDEO_SOURCE=virtual
# GPU zero-copy capture (dmabuf → CUDA → NVENC / VAAPI / Vulkan) is ON by default and falls back to
# CPU automatically. No need to set it. Set to 0 only to force the CPU path.
# PUNKTFUNK_ZEROCOPY=0
# GPU zero-copy capture (EGL/Vulkan → CUDA → NVENC). Falls back to CPU automatically.
PUNKTFUNK_ZEROCOPY=1
# --- Bazzite / SteamOS-like host: host-managed Steam-Deck-UI session -----------------------
# The host LAUNCHES gamescope-session-plus headless AT THE CLIENT'S mode (so games see the
-7
View File
@@ -108,13 +108,6 @@
"display_layout_help": "Automatisch ordnet die Anzeigen nebeneinander an (links nach rechts). Manuell: Du platzierst jede selbst — ein X/Y-Editor pro Anzeige erscheint im Abschnitt „Aktive Displays“ unten, sobald zwei oder mehr streamen.",
"display_layout_auto_row": "Automatisch (nebeneinander)",
"display_layout_manual": "Manuell",
"display_preset_custom_label": "Eigene Voreinstellungen",
"display_preset_save_as": "Als Voreinstellung speichern…",
"display_preset_name": "Name der Voreinstellung",
"display_preset_edit": "Umbenennen",
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
"display_preset_delete": "Löschen",
"display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?",
"clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name",
-7
View File
@@ -108,13 +108,6 @@
"display_layout_help": "Auto lays displays out side by side, left to right. Manual: you position each one yourself — a per-display X/Y editor appears in the Live displays section below once two or more are streaming.",
"display_layout_auto_row": "Auto (side by side)",
"display_layout_manual": "Manual",
"display_preset_custom_label": "Custom presets",
"display_preset_save_as": "Save as preset…",
"display_preset_name": "Preset name",
"display_preset_edit": "Rename",
"display_preset_update": "Update to current settings",
"display_preset_delete": "Delete",
"display_preset_delete_confirm": "Delete this custom preset?",
"clients_title": "Paired clients",
"clients_empty": "No paired clients yet.",
"clients_name": "Name",
+3 -214
View File
@@ -1,22 +1,17 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { type FC, type MouseEvent, type ReactNode, useEffect, useState } from "react";
import { type FC, type ReactNode, useEffect, useState } from "react";
import {
getGetDisplayStateQueryKey,
getGetDisplaySettingsQueryKey,
useCreateCustomPreset,
useDeleteCustomPreset,
useGetDisplaySettings,
useGetDisplayState,
useReleaseDisplay,
useSetDisplayLayout,
useSetDisplaySettings,
useUpdateCustomPreset,
} from "@/api/gen/display/display";
import type {
ApiDisplayInfo,
CustomPreset,
DisplayPolicy,
EffectivePolicy,
GameSession,
@@ -80,7 +75,6 @@ export const DisplaySection: FC = () => {
draft={draft}
setDraft={setDraft}
presets={q.data.presets}
customPresets={q.data.custom_presets}
apply={apply}
busy={save.isPending}
error={apiErrorMessage(save.error)}
@@ -115,23 +109,10 @@ const DisplayForm: FC<{
draft: DisplayPolicy;
setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[];
customPresets: CustomPreset[];
apply: (p: DisplayPolicy) => void;
busy: boolean;
error?: string;
}> = ({ draft, setDraft, presets, customPresets, apply, busy, error }) => {
const qc = useQueryClient();
const createPreset = useCreateCustomPreset();
const updatePreset = useUpdateCustomPreset();
const deletePreset = useDeleteCustomPreset();
const invalidateSettings = () =>
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
const presetBusy =
createPreset.isPending || updatePreset.isPending || deletePreset.isPending;
const presetError = apiErrorMessage(
createPreset.error ?? updatePreset.error ?? deletePreset.error,
);
}> = ({ draft, setDraft, presets, apply, busy, error }) => {
const preset: Preset = draft.preset ?? "custom";
const isCustom = preset === "custom";
@@ -169,56 +150,6 @@ const DisplayForm: FC<{
}
};
// Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the
// one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…).
const applyCustomPreset = (p: CustomPreset) =>
apply({
version: 1,
preset: "custom",
...p.fields,
game_session: p.game_session ?? "auto",
});
// A custom card is "current" when the in-force policy is a Custom one whose fields + game-session
// value-match this preset (there is no id on DisplayPolicy — match by value).
const customSelected = (p: CustomPreset): boolean =>
isCustom &&
(draft.game_session ?? "auto") === (p.game_session ?? "auto") &&
deepEqual(effective, p.fields);
const anyCustomSelected = customPresets.some(customSelected);
// Save the currently-in-force behavior (built-in OR hand-edited) as a new named preset.
const saveAsPreset = () => {
const name = prompt(m.display_preset_name())?.trim();
if (!name) return; // cancelled or empty
createPreset.mutate(
{
data: { name, fields: effective, game_session: draft.game_session ?? "auto" },
},
{ onSuccess: invalidateSettings },
);
};
const renamePreset = (p: CustomPreset) => {
const name = prompt(m.display_preset_name(), p.name)?.trim();
if (!name) return;
updatePreset.mutate(
{ id: p.id, data: { name, fields: p.fields, game_session: p.game_session ?? "auto" } },
{ onSuccess: invalidateSettings },
);
};
const updatePresetToCurrent = (p: CustomPreset) =>
updatePreset.mutate(
{
id: p.id,
data: { name: p.name, fields: effective, game_session: draft.game_session ?? "auto" },
},
{ onSuccess: invalidateSettings },
);
const removePreset = (p: CustomPreset) => {
if (!confirm(m.display_preset_delete_confirm())) return;
deletePreset.mutate({ id: p.id }, { onSuccess: invalidateSettings });
};
const ka = customFields.keep_alive;
// The duration value, remembered across the Off/Keep toggle so switching back restores it.
const [keepSecs, setKeepSecs] = useState(ka.mode === "duration" ? ka.seconds : 300);
@@ -233,9 +164,7 @@ const DisplayForm: FC<{
const p = presets.find((x) => x.id === id);
const fields = id === "custom" ? undefined : p?.fields;
const summary = id === "custom" ? m.display_custom_desc() : p?.summary;
// The built-in "Custom" card is the hand-edit mode; when the active Custom policy
// value-matches a saved preset, that preset's card owns the "current" ring instead.
const selected = preset === id && !(id === "custom" && anyCustomSelected);
const selected = preset === id;
const soon = DISABLED_PRESETS.has(id);
const disabled = busy || soon;
const pick = () => {
@@ -292,44 +221,6 @@ const DisplayForm: FC<{
</div>
</div>
{/* Custom presets the operator's saved field-bundles, rendered like the built-ins but
editable/deletable, plus a "Save as preset" that captures the current effective behavior. */}
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-base font-semibold">
{m.display_preset_custom_label()}
</Label>
<Button
size="sm"
variant="outline"
disabled={busy || presetBusy}
onClick={saveAsPreset}
>
<Plus className="mr-1 size-4" />
{m.display_preset_save_as()}
</Button>
</div>
{customPresets.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{customPresets.map((p) => (
<CustomPresetCard
key={p.id}
preset={p}
selected={customSelected(p)}
busy={busy || presetBusy}
onApply={() => applyCustomPreset(p)}
onRename={() => renamePreset(p)}
onUpdate={() => updatePresetToCurrent(p)}
onDelete={() => removePreset(p)}
/>
))}
</div>
)}
{presetError && (
<p className="text-sm text-amber-600 dark:text-amber-500">{presetError}</p>
)}
</div>
{/* Custom: every option by hand */}
{isCustom && (
<div className="space-y-6 rounded-lg border p-5">
@@ -521,95 +412,6 @@ const Choice: FC<{
</Field>
);
/**
* One saved custom preset the same interactive card as the built-ins (click to apply writes a
* `Custom` policy carrying `preset.fields`), plus rename / update-to-current / delete affordances
* (each stops propagation so it doesn't also fire the card's apply). Field badges mirror the
* built-ins; the game-session badge shows only when it isn't the default `auto`.
*/
const CustomPresetCard: FC<{
preset: CustomPreset;
selected: boolean;
busy: boolean;
onApply: () => void;
onRename: () => void;
onUpdate: () => void;
onDelete: () => void;
}> = ({ preset, selected, busy, onApply, onRename, onUpdate, onDelete }) => {
const fields = preset.fields;
const stop = (fn: () => void) => (e: MouseEvent) => {
e.stopPropagation();
if (!busy) fn();
};
return (
<Card
interactive
role="button"
tabIndex={busy ? -1 : 0}
aria-pressed={selected}
aria-disabled={busy || undefined}
onClick={() => !busy && onApply()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!busy) onApply();
}
}}
className={cn(
"flex h-full flex-col p-4",
busy ? "cursor-not-allowed opacity-60" : "cursor-pointer",
selected && "ring-2 ring-primary",
)}
>
<div className="flex items-start justify-between gap-2">
<span className="min-w-0 truncate text-base font-semibold">{preset.name}</span>
<div className="flex shrink-0 items-center gap-1">
{selected && <Badge variant="success">{m.display_preset_current()}</Badge>}
<Button
size="icon"
variant="ghost"
disabled={busy}
title={m.display_preset_edit()}
aria-label={m.display_preset_edit()}
onClick={stop(onRename)}
>
<Pencil className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
disabled={busy}
title={m.display_preset_update()}
aria-label={m.display_preset_update()}
onClick={stop(onUpdate)}
>
<RefreshCw className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
disabled={busy}
title={m.display_preset_delete()}
aria-label={m.display_preset_delete()}
onClick={stop(onDelete)}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
<div className="mt-auto flex flex-wrap gap-1.5 pt-3">
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge>
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge>
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge>
{(preset.game_session ?? "auto") !== "auto" && (
<Badge variant="secondary">{tr(GAME_SESSION_LABEL, preset.game_session)}</Badge>
)}
</div>
</Card>
);
};
/**
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
* for lingering/pinned ones (active displays can't be released — that's session control).
@@ -838,19 +640,6 @@ const GAME_SESSION_LABEL: Record<string, () => string> = {
dedicated: m.display_game_session_dedicated,
};
/** Structural equality for the value-match of a custom preset's fields against the effective policy
* (handles the nested `keep_alive` variants + `layout.positions` map; key order doesn't matter). */
const deepEqual = (a: unknown, b: unknown): boolean => {
if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
const ak = Object.keys(a as object);
const bk = Object.keys(b as object);
if (ak.length !== bk.length) return false;
return ak.every((k) =>
deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
);
};
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
const fn = key == null ? undefined : map[key];