Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef320662b | |||
| 04309d0ad9 |
Generated
+9
-9
@@ -2129,7 +2129,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2908,7 +2908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -2922,7 +2922,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2945,7 +2945,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -2968,7 +2968,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2999,7 +2999,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3085,7 +3085,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ members = [
|
|||||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.8.1"
|
version = "0.8.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1
-286
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.8.1"
|
"version": "0.8.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/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": {
|
"/api/v1/display/release": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"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": {
|
"DisplayLayoutRequest": {
|
||||||
"type": "object",
|
"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`).",
|
"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",
|
"configured",
|
||||||
"effective",
|
"effective",
|
||||||
"presets",
|
"presets",
|
||||||
"custom_presets",
|
|
||||||
"enforced"
|
"enforced"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2569,13 +2291,6 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "True once a `display-settings.json` exists (the console has configured this host)."
|
"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": {
|
"effective": {
|
||||||
"$ref": "#/components/schemas/EffectivePolicy",
|
"$ref": "#/components/schemas/EffectivePolicy",
|
||||||
"description": "The effective (preset-expanded) policy currently in force."
|
"description": "The effective (preset-expanded) policy currently in force."
|
||||||
|
|||||||
@@ -208,8 +208,6 @@ fun GamepadShell(
|
|||||||
GamepadScreen.Library -> libraryHost?.let { host ->
|
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||||
LibraryScreen(
|
LibraryScreen(
|
||||||
host = host,
|
host = host,
|
||||||
settings = settings,
|
|
||||||
onLaunched = onConnected,
|
|
||||||
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||||
navActive = s == screen,
|
navActive = s == screen,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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
|
* 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
|
* 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.
|
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
|
||||||
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
|
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
|
// The one place the full nativeConnect is issued (shared by the normal connect and the
|
||||||
// desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
|
// request-access path), including the HDR/gamepad derivation both need.
|
||||||
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
|
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)
|
// 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 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
|
// 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
|
// Wake-aware connect. If the target is a saved host with a learned MAC that ISN'T currently
|
||||||
// host with a learned MAC that ISN'T currently advertising (asleep/off, or just missing from
|
// advertising (asleep/off), wake it and WAIT for it to reappear on mDNS (WakeController shows the
|
||||||
// mDNS), wake it and WAIT for it to reappear on mDNS (WakeController shows the "Waking…" overlay)
|
// "Waking…" overlay) before dialing — discovery stays running meanwhile so we can see it come
|
||||||
// before dialing — discovery stays running meanwhile so we can see it come back. A fire-and-forget
|
// back. A fire-and-forget packet + the connect timeout wasn't enough for a cold boot. Otherwise
|
||||||
// packet + the connect timeout wasn't enough for a cold boot. Otherwise (auto-wake off, no MAC, or
|
// dial straight through.
|
||||||
// already seen live) dial straight through.
|
|
||||||
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
|
||||||
if (identity == null) {
|
if (identity == null) {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
@@ -231,7 +248,7 @@ fun ConnectScreen(
|
|||||||
fun liveAdvert(): DiscoveredHost? =
|
fun liveAdvert(): DiscoveredHost? =
|
||||||
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
if (kh != null) discovered.firstOrNull { kh.matches(it) }
|
||||||
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
|
||||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
if (macs.isNotEmpty() && liveAdvert() == null) {
|
||||||
waker.start(
|
waker.start(
|
||||||
hostName = name,
|
hostName = name,
|
||||||
connectsAfter = true,
|
connectsAfter = true,
|
||||||
|
|||||||
@@ -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
|
package io.unom.punktfunk
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
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.LibraryClient
|
||||||
import io.unom.punktfunk.kit.library.LibraryResult
|
import io.unom.punktfunk.kit.library.LibraryResult
|
||||||
import io.unom.punktfunk.kit.library.mtlsHttpClient
|
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.IdentityStore
|
||||||
import io.unom.punktfunk.kit.security.KnownHost
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||||
@@ -76,27 +73,17 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
private sealed class LibState {
|
private sealed class LibState {
|
||||||
object Loading : 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) : LibState()
|
||||||
data class Ready(val games: List<GameEntry>, val loader: ImageLoader, val identity: ClientIdentity) : LibState()
|
|
||||||
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
data class Message(val text: String) : LibState() // unauthorized / empty / error
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) {
|
||||||
host: KnownHost,
|
|
||||||
settings: Settings,
|
|
||||||
onLaunched: (Long) -> Unit,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
navActive: Boolean = true,
|
|
||||||
) {
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
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) {
|
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||||
state = LibState.Loading
|
state = LibState.Loading
|
||||||
@@ -114,7 +101,7 @@ fun LibraryScreen(
|
|||||||
LibState.Message("No games found on this host.")
|
LibState.Message("No games found on this host.")
|
||||||
} else {
|
} else {
|
||||||
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
|
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.Unauthorized -> LibState.Message(res.message)
|
||||||
is LibraryResult.Error -> LibState.Message(res.message)
|
is LibraryResult.Error -> LibState.Message(res.message)
|
||||||
@@ -131,45 +118,11 @@ fun LibraryScreen(
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is LibState.Loading -> LoadingState()
|
is LibState.Loading -> LoadingState()
|
||||||
is LibState.Message -> MessageState(s.text)
|
is LibState.Message -> MessageState(s.text)
|
||||||
is LibState.Ready -> Coverflow(s.games, s.loader, navActive && !launching) { game ->
|
is LibState.Ready -> Coverflow(s.games, s.loader, navActive)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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
|
// 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).
|
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||||
Box(
|
Box(
|
||||||
@@ -177,13 +130,7 @@ fun LibraryScreen(
|
|||||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||||
.padding(ConsoleLegendInset),
|
.padding(ConsoleLegendInset),
|
||||||
) {
|
) {
|
||||||
GamepadHintBar(
|
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState)
|
||||||
buildList {
|
|
||||||
if (state is LibState.Ready) add(PadGlyph.hint('A', "Launch"))
|
|
||||||
add(PadGlyph.hint('B', "Close", onClick = onBack))
|
|
||||||
},
|
|
||||||
hazeState = hazeState,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,12 +155,7 @@ private fun MessageState(text: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Coverflow(
|
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) {
|
||||||
games: List<GameEntry>,
|
|
||||||
loader: ImageLoader,
|
|
||||||
navActive: Boolean,
|
|
||||||
onLaunch: (GameEntry) -> Unit,
|
|
||||||
) {
|
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
|
// 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)
|
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
|
||||||
@@ -225,15 +167,16 @@ private fun Coverflow(
|
|||||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||||
val current = games.getOrNull(navTarget)
|
val current = games.getOrNull(navTarget)
|
||||||
|
|
||||||
// Controller nav: the pad drives the coverflow. Left/right steps a coalesced target the pager
|
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a
|
||||||
// chases; A launches the centred title; B closes via the screen's BackHandler.
|
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes
|
||||||
|
// via the screen's BackHandler.
|
||||||
GamepadNavEffect(
|
GamepadNavEffect(
|
||||||
active = navActive && games.isNotEmpty(),
|
active = navActive && games.isNotEmpty(),
|
||||||
onMove = { dir ->
|
onMove = { dir ->
|
||||||
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
|
||||||
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
|
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) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||||
@@ -255,11 +198,6 @@ private fun Coverflow(
|
|||||||
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
.zIndex(-d) // centred cover on top, neighbours stacked behind
|
||||||
.width(coverWidth)
|
.width(coverWidth)
|
||||||
.height(coverHeight)
|
.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 {
|
.graphicsLayer {
|
||||||
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
// 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 —
|
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
||||||
|
|||||||
@@ -51,12 +51,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
|
* 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
|
* 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).
|
* 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)
|
var lastPadIsGamepad by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
@@ -64,7 +60,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
lastPadIsGamepad = !isTvDevice(this)
|
|
||||||
resolveHighRefreshMode()
|
resolveHighRefreshMode()
|
||||||
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
|
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
|
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
|
||||||
|
|||||||
@@ -55,23 +55,13 @@ data class Settings(
|
|||||||
*/
|
*/
|
||||||
val libraryEnabled: Boolean = true,
|
val libraryEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* "Low-latency mode (experimental)" — the master switch over the whole latency overhaul: decoder
|
* Aggressive decoder latency tuning — the master escape hatch. On (default): the decoder runs
|
||||||
* ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF
|
* the full low-latency profile (per-SoC vendor keys + max-clock operating-rate on Qualcomm).
|
||||||
* max-performance, game-tagged AAudio, DSCP marking on the media sockets, the Wi-Fi low-latency
|
* Off: a conservative profile (the standard `low-latency` key only), for a device that thermally
|
||||||
* lock, HDMI ALLM, and the forced TV mode switch. Off (default): the original pre-overhaul
|
* throttles or misbehaves under the aggressive clocks. Decoder ranking, the Wi-Fi low-latency
|
||||||
* pipeline, kept byte-for-byte as the known-good baseline — the overhaul regressed badly on some
|
* lock and HDMI game-mode signalling stay on regardless — they're harmless.
|
||||||
* phones, so it's opt-in until it's proven per-device.
|
|
||||||
*/
|
*/
|
||||||
val lowLatencyMode: Boolean = false,
|
val lowLatencyMode: Boolean = true,
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** [Settings.touchMode] values; persisted by name. */
|
/** [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,
|
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, false),
|
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: Settings) {
|
fun save(s: Settings) {
|
||||||
@@ -121,7 +110,6 @@ class SettingsStore(context: Context) {
|
|||||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,15 +128,7 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_TOUCH_MODE = "touch_mode"
|
const val K_TOUCH_MODE = "touch_mode"
|
||||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||||
const val K_LIBRARY = "library_enabled"
|
const val K_LIBRARY = "library_enabled"
|
||||||
|
const val K_LOW_LATENCY = "low_latency_mode"
|
||||||
/**
|
|
||||||
* 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"
|
|
||||||
|
|
||||||
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
|
||||||
const val K_TRACKPAD = "trackpad_mode"
|
const val K_TRACKPAD = "trackpad_mode"
|
||||||
@@ -246,10 +226,6 @@ val BITRATE_OPTIONS = listOf(
|
|||||||
20_000 to "20 Mbps",
|
20_000 to "20 Mbps",
|
||||||
50_000 to "50 Mbps",
|
50_000 to "50 Mbps",
|
||||||
100_000 to "100 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. */
|
/** index = CompositorPref wire byte. */
|
||||||
|
|||||||
@@ -326,10 +326,9 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
|||||||
) { c -> update(s.copy(compositor = c)) }
|
) { c -> update(s.copy(compositor = c)) }
|
||||||
|
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "Low-latency mode (experimental)",
|
title = "Low-latency mode",
|
||||||
subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " +
|
subtitle = "Run the decoder at max clocks for the lowest latency. Turn off only if a " +
|
||||||
"decode, Wi-Fi and HDMI hints). Can lower latency, but may stutter or glitch on " +
|
"device overheats or glitches during long sessions.",
|
||||||
"some devices — turn off if the stream misbehaves.",
|
|
||||||
checked = s.lowLatencyMode,
|
checked = s.lowLatencyMode,
|
||||||
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
|
||||||
)
|
)
|
||||||
@@ -395,14 +394,6 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
|
|||||||
checked = s.libraryEnabled,
|
checked = s.libraryEnabled,
|
||||||
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
|
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(
|
ToggleRow(
|
||||||
title = "Stats overlay",
|
title = "Stats overlay",
|
||||||
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import android.os.Build
|
|||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -64,10 +63,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
|
||||||
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
|
||||||
val touchMode = initialSettings.touchMode
|
val touchMode = initialSettings.touchMode
|
||||||
// "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the
|
// Master low-latency toggle, resolved once for the session and passed to the decoder at start.
|
||||||
// default) runs the original pre-overhaul pipeline; on enables the whole aggressive stack —
|
|
||||||
// decoder ranking + vendor keys + async loop (native side), the Wi-Fi low-latency lock and
|
|
||||||
// HDMI ALLM below, game-tagged audio, and DSCP marking (applied earlier, at connect).
|
|
||||||
val lowLatencyMode = initialSettings.lowLatencyMode
|
val lowLatencyMode = initialSettings.lowLatencyMode
|
||||||
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
|
// 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.
|
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
|
||||||
@@ -86,31 +82,6 @@ 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
|
// 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
|
// 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
|
// again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the
|
||||||
@@ -121,9 +92,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
|
// 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
|
// 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.
|
// beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
|
||||||
// Part of the experimental low-latency stack — not created at all when the toggle is off.
|
|
||||||
val wifiLock = remember(handle) {
|
val wifiLock = remember(handle) {
|
||||||
if (!lowLatencyMode) return@remember null
|
|
||||||
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||||
@@ -138,9 +107,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
runCatching { wifiLock?.acquire() }
|
runCatching { wifiLock?.acquire() }
|
||||||
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
|
// 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
|
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API 30+.
|
||||||
// 30+. Part of the experimental low-latency stack.
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
window?.setPreferMinimalPostProcessing(true)
|
window?.setPreferMinimalPostProcessing(true)
|
||||||
}
|
}
|
||||||
controller?.let {
|
controller?.let {
|
||||||
@@ -156,9 +124,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
activity?.streamHandle = handle // route hardware keys to this session
|
activity?.streamHandle = handle // route hardware keys to this session
|
||||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
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
|
activity?.requestStreamExit = onDisconnect // Select+Start+L1+R1 chord leaves the stream
|
||||||
// the keep-alive linger), unlike a host-ended / backgrounded drop.
|
|
||||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
|
||||||
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
|
||||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||||
@@ -172,7 +138,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
|
||||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
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)
|
window?.setPreferMinimalPostProcessing(false)
|
||||||
}
|
}
|
||||||
runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
|
runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
|
||||||
@@ -187,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 { onDisconnect() }
|
||||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
@@ -197,13 +162,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
SurfaceView(ctx).apply {
|
SurfaceView(ctx).apply {
|
||||||
holder.addCallback(object : SurfaceHolder.Callback {
|
holder.addCallback(object : SurfaceHolder.Callback {
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
// Low-latency mode: rank MediaCodecList decoders for the negotiated
|
// Rank MediaCodecList decoders for the negotiated MIME (framework-only
|
||||||
// MIME (framework-only API) and hand the chosen one to Rust, which
|
// API) and hand the chosen one to Rust, which creates it by name and
|
||||||
// creates it by name and applies the per-SoC vendor low-latency keys.
|
// 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.
|
|
||||||
val mime = NativeBridge.nativeVideoMime(handle)
|
val mime = NativeBridge.nativeVideoMime(handle)
|
||||||
val choice = if (lowLatencyMode) VideoDecoders.pickDecoder(mime) else null
|
val choice = VideoDecoders.pickDecoder(mime)
|
||||||
NativeBridge.nativeStartVideo(
|
NativeBridge.nativeStartVideo(
|
||||||
handle,
|
handle,
|
||||||
holder.surface,
|
holder.surface,
|
||||||
@@ -212,7 +175,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
choice?.lowLatencyFeature ?: false,
|
choice?.lowLatencyFeature ?: false,
|
||||||
isTv,
|
isTv,
|
||||||
)
|
)
|
||||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
NativeBridge.nativeStartAudio(handle)
|
||||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,23 +51,11 @@ object NativeBridge {
|
|||||||
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
|
||||||
preferredCodec: Int,
|
preferredCodec: Int,
|
||||||
timeoutMs: 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
|
): Long
|
||||||
|
|
||||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||||
external fun nativeHostFingerprint(handle: Long): String
|
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
|
* 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).
|
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||||
@@ -82,14 +70,6 @@ object NativeBridge {
|
|||||||
name: String,
|
name: String,
|
||||||
): 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`. */
|
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
|
||||||
external fun nativeClose(handle: Long)
|
external fun nativeClose(handle: Long)
|
||||||
|
|
||||||
@@ -123,15 +103,6 @@ object NativeBridge {
|
|||||||
*/
|
*/
|
||||||
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
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"` /
|
* 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
|
* `"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
|
* 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
|
* 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
|
* 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
|
* the MIME); [lowLatencyMode] is the user's master toggle (default on → aggressive per-SoC
|
||||||
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
|
* tuning; off → conservative); [lowLatencyFeature] is whether [decoderName] advertised
|
||||||
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
|
* `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI mode switch to the stream
|
||||||
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
|
* refresh on TV boxes (vs. the softer seamless hint on phones). No-op if already started.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
external fun nativeStartVideo(
|
external fun nativeStartVideo(
|
||||||
handle: Long,
|
handle: Long,
|
||||||
@@ -194,12 +163,10 @@ object NativeBridge {
|
|||||||
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
|
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust.
|
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. No-op
|
||||||
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
|
* if already started. Best-effort — a failure leaves video streaming.
|
||||||
* HAL's game-audio routing. 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`. */
|
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
|
||||||
external fun nativeStopAudio(handle: Long)
|
external fun nativeStopAudio(handle: Long)
|
||||||
|
|||||||
@@ -57,17 +57,7 @@ object VideoDecoders {
|
|||||||
val name = info.name
|
val name = info.name
|
||||||
val lower = name.lowercase()
|
val lower = name.lowercase()
|
||||||
if (BLOCKED_PREFIXES.any { lower.startsWith(it) } || lower in BLOCKED_EXACT) continue
|
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 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) {
|
val hardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
info.isHardwareAccelerated
|
info.isHardwareAccelerated
|
||||||
|
|||||||
@@ -103,10 +103,8 @@ pub struct HintSession {
|
|||||||
impl HintSession {
|
impl HintSession {
|
||||||
/// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds.
|
/// 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
|
/// `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
|
/// runs unhinted (a no-op, not an error).
|
||||||
/// mode) additionally biases the governor away from power efficiency (API 35+); off, the
|
pub fn create(target_ns: i64, tids: &[i32]) -> Option<Self> {
|
||||||
/// 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> {
|
|
||||||
if target_ns <= 0 || tids.is_empty() {
|
if target_ns <= 0 || tids.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -121,12 +119,10 @@ impl HintSession {
|
|||||||
// Tell the governor NOT to bias this session toward power efficiency (API 35+): our loop is
|
// 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.
|
// latency-critical, so we want it kept on fast cores at high clocks over battery savings.
|
||||||
// Best-effort; absent below API 35.
|
// Best-effort; absent below API 35.
|
||||||
if prefer_performance {
|
|
||||||
if let Some(f) = api.set_prefer_power_efficiency {
|
if let Some(f) = api.set_prefer_power_efficiency {
|
||||||
// SAFETY: `session` is the live session just created; the fn takes it + a bool.
|
// SAFETY: `session` is the live session just created; the fn takes it + a bool.
|
||||||
unsafe { f(session, false) };
|
unsafe { f(session, false) };
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Some(Self { api, session })
|
Some(Self { api, session })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,10 +116,8 @@ pub struct AudioPlayback {
|
|||||||
impl AudioPlayback {
|
impl AudioPlayback {
|
||||||
/// Open AAudio (LowLatency, 48 kHz/f32, the host-resolved channel layout) with a realtime
|
/// 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
|
/// 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
|
/// caller leaves video streaming).
|
||||||
/// stream usage=Game for the HAL's game-audio routing; off, the stream is untagged as it was
|
pub fn start(client: Arc<NativeClient>) -> Option<AudioPlayback> {
|
||||||
/// before the overhaul.
|
|
||||||
pub fn start(client: Arc<NativeClient>, game_audio: bool) -> Option<AudioPlayback> {
|
|
||||||
// Build playback from the host-RESOLVED channel count (never the request): 2 = stereo /
|
// 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.
|
// 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;
|
let channels = punktfunk_core::audio::normalize_channels(client.audio_channels) as usize;
|
||||||
@@ -228,7 +226,7 @@ impl AudioPlayback {
|
|||||||
AudioCallbackResult::Continue
|
AudioCallbackResult::Continue
|
||||||
};
|
};
|
||||||
|
|
||||||
let builder = AudioStreamBuilder::new()?
|
let stream = AudioStreamBuilder::new()?
|
||||||
.direction(AudioDirection::Output)
|
.direction(AudioDirection::Output)
|
||||||
.sample_rate(SAMPLE_RATE)
|
.sample_rate(SAMPLE_RATE)
|
||||||
// The wire order (FL FR FC LFE RL RR SL SR) is the standard AAudio/Android channel
|
// 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
|
// from `channel_count` (the ndk crate's builder exposes no setChannelMask); the host
|
||||||
// captures + Opus-encodes in exactly this order.
|
// captures + Opus-encodes in exactly this order.
|
||||||
.channel_count(channels as i32)
|
.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
|
// 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
|
// 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
|
// game-mode profile. Advisory — ignored where the device has no such policy.
|
||||||
// the experimental low-latency stack; off, the stream stays untagged.
|
|
||||||
let builder = if game_audio {
|
|
||||||
builder
|
|
||||||
.usage(AudioUsage::Game)
|
.usage(AudioUsage::Game)
|
||||||
.content_type(AudioContentType::Movie)
|
.content_type(AudioContentType::Movie)
|
||||||
} else {
|
|
||||||
builder
|
|
||||||
};
|
|
||||||
let stream = builder
|
|
||||||
.performance_mode(AudioPerformanceMode::LowLatency)
|
.performance_mode(AudioPerformanceMode::LowLatency)
|
||||||
.sharing_mode(sharing)
|
.sharing_mode(sharing)
|
||||||
.data_callback(Box::new(callback))
|
.data_callback(Box::new(callback))
|
||||||
|
|||||||
@@ -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.
|
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
||||||
const PENDING_SPLIT_CAP: usize = 256;
|
const PENDING_SPLIT_CAP: usize = 256;
|
||||||
|
|
||||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
/// Whether to run the event-driven async decode loop (default) or the synchronous poll loop kept as
|
||||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
/// a bring-up fallback. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop
|
||||||
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
/// presents a decoded frame the instant it's ready instead of waiting out a poll interval.
|
||||||
/// the user's "Low-latency mode (experimental)" toggle is ON — off, the sync loop always runs (the
|
|
||||||
/// original pipeline).
|
|
||||||
const USE_ASYNC_DECODE: bool = true;
|
const USE_ASYNC_DECODE: bool = true;
|
||||||
|
|
||||||
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
/// 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
|
/// 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.
|
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
||||||
pub ll_feature: bool,
|
pub ll_feature: bool,
|
||||||
/// The user's "Low-latency mode (experimental)" master toggle. On ⇒ the full overhaul: async
|
/// The user's "Low-latency mode" master toggle (default on ⇒ full aggressive profile; off ⇒
|
||||||
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
|
/// conservative, an escape hatch for a device that throttles under the clocks).
|
||||||
/// mode switch. Off (default) ⇒ the original pre-overhaul pipeline, kept as the known-good
|
|
||||||
/// baseline while the overhaul is experimental.
|
|
||||||
pub low_latency_mode: bool,
|
pub low_latency_mode: bool,
|
||||||
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
/// 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.
|
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
||||||
@@ -71,16 +67,17 @@ pub fn run(
|
|||||||
stats: Arc<crate::stats::VideoStats>,
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
opts: DecodeOptions,
|
opts: DecodeOptions,
|
||||||
) {
|
) {
|
||||||
if opts.low_latency_mode && USE_ASYNC_DECODE {
|
if USE_ASYNC_DECODE {
|
||||||
run_async(client, window, shutdown, stats, opts);
|
run_async(client, window, shutdown, stats, opts);
|
||||||
} else {
|
} else {
|
||||||
run_sync(client, window, shutdown, stats, opts);
|
run_sync(client, window, shutdown, stats, opts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
|
/// The synchronous poll loop — the original decode path, kept as a bring-up fallback behind
|
||||||
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
|
/// [`USE_ASYNC_DECODE`]. Feeds and drains on this one thread; the only blocking wait is a short
|
||||||
/// only blocking wait is a short output dequeue while input is backed up.
|
/// output dequeue while input is backed up.
|
||||||
|
#[allow(dead_code)]
|
||||||
fn run_sync(
|
fn run_sync(
|
||||||
client: Arc<NativeClient>,
|
client: Arc<NativeClient>,
|
||||||
window: NativeWindow,
|
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
|
// 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
|
// 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).
|
// 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;
|
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
|
||||||
// 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)
|
|
||||||
{
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||||
mode.refresh_hz
|
mode.refresh_hz
|
||||||
@@ -326,12 +319,8 @@ fn run_sync(
|
|||||||
// or where the platform declines → `None`, and the loop runs unhinted).
|
// or where the platform declines → `None`, and the loop runs unhinted).
|
||||||
hint_tried = true;
|
hint_tried = true;
|
||||||
let tids = client.hot_thread_ids();
|
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);
|
boost_hot_threads(&tids);
|
||||||
}
|
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
|
||||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||||
if hint.is_some() {
|
if hint.is_some() {
|
||||||
@@ -407,15 +396,12 @@ fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
|
|||||||
MediaCodec::from_decoder_type(mime)
|
MediaCodec::from_decoder_type(mime)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the low-latency MediaFormat keys for `codec_name`.
|
/// 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"
|
||||||
/// `aggressive` = the "Low-latency mode (experimental)" master toggle. **Off** (default) ⇒ the
|
/// master toggle) we additionally set MediaTek's `vdec-lowlatency` (unconditionally — ignored off
|
||||||
/// pre-overhaul key set, byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor
|
/// MediaTek), the per-SoC vendor extension keys (gated on the decoder-name prefix the way
|
||||||
/// twin, `priority = 0` AND `operating-rate = MAX` set together — kept as the known-good baseline
|
/// Moonlight-Android does, since a key one vendor honours is meaningless on another), and one clock
|
||||||
/// (the profile every device streamed with before the overhaul). **On** ⇒ the Moonlight-parity
|
/// hint. Off ⇒ the standard key only, a gentler profile for a device that throttles under max clocks.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
|
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
|
||||||
/// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA
|
/// 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.
|
// Standard key: request the no-reorder low-latency path where the platform decoder supports it.
|
||||||
format.set_i32("low-latency", 1);
|
format.set_i32("low-latency", 1);
|
||||||
if !aggressive {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes).
|
// 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.width,
|
||||||
mode.height
|
mode.height
|
||||||
);
|
);
|
||||||
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
|
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
|
||||||
// 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)
|
|
||||||
{
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
|
||||||
mode.refresh_hz
|
mode.refresh_hz
|
||||||
@@ -740,12 +716,8 @@ fn run_async(
|
|||||||
if !hint_tried {
|
if !hint_tried {
|
||||||
hint_tried = true;
|
hint_tried = true;
|
||||||
let tids = client.hot_thread_ids();
|
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);
|
boost_hot_threads(&tids);
|
||||||
}
|
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
|
||||||
hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode);
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
|
||||||
if hint.is_some() {
|
if hint.is_some() {
|
||||||
|
|||||||
@@ -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,
|
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`.
|
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs): Long`.
|
||||||
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
|
|
||||||
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
|
/// `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).
|
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
|
||||||
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
|
/// `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,
|
audio_channels: jint,
|
||||||
preferred_codec: jint,
|
preferred_codec: jint,
|
||||||
timeout_ms: jint,
|
timeout_ms: jint,
|
||||||
launch: JString<'local>,
|
|
||||||
) -> jlong {
|
) -> jlong {
|
||||||
let host: String = match env.get_string(&host) {
|
let host: String = match env.get_string(&host) {
|
||||||
Ok(s) => s.into(),
|
Ok(s) => s.into(),
|
||||||
@@ -90,13 +74,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let key: String = env.get_string(&key_pem).map(Into::into).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();
|
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() {
|
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
|
||||||
None
|
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.
|
// + 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,
|
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
|
||||||
preferred_codec.clamp(0, u8::MAX as jint) as u8,
|
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
|
pin, // Some → Crypto on host-fp mismatch
|
||||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
// 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
|
/// `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
|
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
||||||
/// connect. `""` on a `0` handle.
|
/// 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
|
/// `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
|
/// 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
|
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||||
|
|||||||
@@ -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.
|
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
||||||
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
|
/// started or on a `0` handle. Best-effort: a failure leaves video streaming.
|
||||||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
|
||||||
/// streaming.
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
_this: JObject,
|
_this: JObject,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
low_latency_mode: jboolean,
|
|
||||||
) {
|
) {
|
||||||
if handle == 0 {
|
if handle == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -254,7 +251,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
|||||||
if guard.is_some() {
|
if guard.is_some() {
|
||||||
return; // already playing
|
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),
|
Some(p) => *guard = Some(p),
|
||||||
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,7 +432,6 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
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_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.";
|
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;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
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_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.";
|
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()
|
disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tear the session down. `deliberate` (the default) means a user-initiated quit — signal
|
func disconnect() {
|
||||||
/// `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) {
|
|
||||||
statsTimer?.invalidate()
|
statsTimer?.invalidate()
|
||||||
statsTimer = nil
|
statsTimer = nil
|
||||||
let audio = self.audio
|
let audio = self.audio
|
||||||
@@ -297,8 +294,6 @@ final class SessionModel: ObservableObject {
|
|||||||
Task.detached {
|
Task.detached {
|
||||||
audio?.stop()
|
audio?.stop()
|
||||||
feedback?.stop()
|
feedback?.stop()
|
||||||
// Deliberate user quit → tell the host to skip the keep-alive linger (must precede close).
|
|
||||||
if deliberate { conn.disconnectQuit() }
|
|
||||||
conn.close()
|
conn.close()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -326,7 +321,7 @@ final class SessionModel: ObservableObject {
|
|||||||
func sessionEnded() {
|
func sessionEnded() {
|
||||||
guard connection != nil else { return }
|
guard connection != nil else { return }
|
||||||
let name = activeHost?.displayName ?? "host"
|
let name = activeHost?.displayName ?? "host"
|
||||||
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
disconnect()
|
||||||
errorMessage = "Session ended by \(name)."
|
errorMessage = "Session ended by \(name)."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -759,17 +759,6 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_send_input(h, &ev)
|
_ = 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
|
/// Close the connection and free the handle. Safe from any thread, idempotent; waits
|
||||||
/// for in-flight pulls (≤ their timeouts) before tearing down.
|
/// for in-flight pulls (≤ their timeouts) before tearing down.
|
||||||
public func close() {
|
public func close() {
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ public final class InputCapture {
|
|||||||
/// its Esc suppression need it in both states).
|
/// its Esc suppression need it in both states).
|
||||||
private var cmdKeysDown: Set<UInt32> = []
|
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
|
/// 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
|
/// locally; while false the user is interacting with the local UI (dragging the
|
||||||
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
|
||||||
@@ -270,6 +279,12 @@ public final class InputCapture {
|
|||||||
residualY = 0
|
residualY = 0
|
||||||
residualScrollX = 0
|
residualScrollX = 0
|
||||||
residualScrollY = 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
|
/// 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
|
/// 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
|
/// as flagsChanged, which carries no down-vs-up. We diff the raw flags against the prior
|
||||||
/// (kVK_Control & co., already L/R-specific); `resolveModifier` recovers the direction
|
/// snapshot to recover each transition, and the changed key's L/R identity from the
|
||||||
/// from the flags. Fed `event.keyCode` + `UInt(event.modifierFlags.rawValue)` — LOW 16
|
/// device-dependent bits in the LOW 16 bits (the .deviceIndependentFlagsMask the ⌘⎋
|
||||||
/// bits intact, they carry the device-dependent L/R bits (the .deviceIndependentFlagsMask
|
/// monitor uses deliberately strips exactly these — do NOT pre-mask here). Each side maps
|
||||||
/// the ⌘⎋ monitor uses deliberately strips exactly these — do NOT pre-mask here).
|
/// to the same L/R modifier VK `hidToVK` already emits, so the host needs no change.
|
||||||
public func handleFlagsChanged(keyCode: UInt16, rawFlags: UInt) {
|
/// Fed `UInt(event.modifierFlags.rawValue)`.
|
||||||
if inputDebug {
|
public func handleFlagsChanged(_ rawFlags: UInt) {
|
||||||
inputLog.debug(
|
// While released we only resync the snapshot, so a modifier held across a capture
|
||||||
"flagsChanged keyCode \(keyCode, privacy: .public) flags 0x\(String(rawFlags, radix: 16), privacy: .public) forwarding \(self.forwarding, privacy: .public)")
|
// toggle doesn't show up as a spurious transition the moment forwarding re-engages.
|
||||||
|
guard forwarding else {
|
||||||
|
prevModFlags = rawFlags
|
||||||
|
return
|
||||||
}
|
}
|
||||||
guard forwarding else { return }
|
// (device-dependent mask, VK). LOW-16-bit masks from IOLLEvent.h (NX_DEVICE*MASK):
|
||||||
guard let (vk, down) = Self.resolveModifier(
|
// Lshift 0x2 Rshift 0x4 | Lctrl 0x1 Rctrl 0x2000 | Lalt 0x20 Ralt 0x40 | Lcmd 0x8 Rcmd 0x10.
|
||||||
keyCode: keyCode, rawFlags: rawFlags, isDown: { pressedVKs.contains($0) })
|
let table: [(UInt, UInt32)] = [
|
||||||
else { return } // Fn / Caps Lock / unknown — nothing the host consumes on this path
|
(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
|
// Keep cmdKeysDown in step (the ⌘⎋ toggle + Esc suppression read it); sendKey
|
||||||
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
|
// adds the VK to pressedVKs so releaseAll/blur flushes a held modifier cleanly.
|
||||||
if vk == 0x5B || vk == 0x5C {
|
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)
|
||||||
}
|
}
|
||||||
|
prevModFlags = rawFlags
|
||||||
/// 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)
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -98,23 +98,5 @@ extension InputCapture {
|
|||||||
m[0x47] = 0x90 // KP clear sits where NumLock is → VK_NUMLOCK. (KP equals 0x51 dropped.)
|
m[0x47] = 0x90 // KP clear sits where NumLock is → VK_NUMLOCK. (KP equals 0x51 dropped.)
|
||||||
return m
|
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
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,13 +346,10 @@ public final class StreamLayerView: NSView {
|
|||||||
super.keyUp(with: event)
|
super.keyUp(with: event)
|
||||||
}
|
}
|
||||||
/// Modifier keys (shift/control/option/command) arrive ONLY as flagsChanged on macOS,
|
/// Modifier keys (shift/control/option/command) arrive ONLY as flagsChanged on macOS,
|
||||||
/// never keyDown/keyUp — the changed key is `event.keyCode`; InputCapture resolves the
|
/// never keyDown/keyUp — InputCapture diffs the raw flags to recover each L/R down/up.
|
||||||
/// down-vs-up direction from the flags (diffing the device-dependent flag bits alone
|
|
||||||
/// proved unreliable — some keyboards omit them, which silently dropped Control).
|
|
||||||
public override func flagsChanged(with event: NSEvent) {
|
public override func flagsChanged(with event: NSEvent) {
|
||||||
if captured, let inputCapture {
|
if captured, let inputCapture {
|
||||||
inputCapture.handleFlagsChanged(
|
inputCapture.handleFlagsChanged(UInt(event.modifierFlags.rawValue))
|
||||||
keyCode: event.keyCode, rawFlags: UInt(event.modifierFlags.rawValue))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
super.flagsChanged(with: event)
|
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
|
|
||||||
@@ -154,21 +154,13 @@ pub fn run() -> glib::ExitCode {
|
|||||||
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
||||||
}
|
}
|
||||||
let app = builder.build();
|
let app = builder.build();
|
||||||
// One SDL context for the whole process: `activate` fires again on every subsequent
|
app.connect_activate(build_ui);
|
||||||
// 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()));
|
|
||||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||||
// keeps GApplication from rejecting unknown options.
|
// keeps GApplication from rejecting unknown options.
|
||||||
app.run_with_args(&[] as &[&str])
|
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() {
|
let identity = match crate::trust::load_or_create_identity() {
|
||||||
Ok(i) => i,
|
Ok(i) => i,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -211,7 +203,7 @@ fn build_ui(gtk_app: &adw::Application, gamepad: crate::gamepad::GamepadService)
|
|||||||
toasts,
|
toasts,
|
||||||
settings: Rc::new(RefCell::new(Settings::load())),
|
settings: Rc::new(RefCell::new(Settings::load())),
|
||||||
identity,
|
identity,
|
||||||
gamepad,
|
gamepad: crate::gamepad::GamepadService::start(),
|
||||||
busy: std::cell::Cell::new(false),
|
busy: std::cell::Cell::new(false),
|
||||||
fullscreen,
|
fullscreen,
|
||||||
// (`--browse` makes cli_connect_request None — browse mode returns to the
|
// (`--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();
|
let app = app.clone();
|
||||||
Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req))
|
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: {
|
on_speed_test: {
|
||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
Rc::new(move |req| speed_test(app.clone(), req))
|
Rc::new(move |req| speed_test(app.clone(), req))
|
||||||
|
|||||||
@@ -168,26 +168,6 @@ pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
|||||||
let _ = known.save();
|
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
|
/// 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.
|
/// most-recent accent). No-op when the fingerprint isn't stored.
|
||||||
pub fn touch_last_used(fp_hex: &str) {
|
pub fn touch_last_used(fp_hex: &str) {
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ impl ConnectRequest {
|
|||||||
/// the library browser).
|
/// the library browser).
|
||||||
pub struct HostsCallbacks {
|
pub struct HostsCallbacks {
|
||||||
pub on_connect: Rc<dyn Fn(ConnectRequest)>,
|
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_speed_test: Rc<dyn Fn(ConnectRequest)>,
|
||||||
pub on_pair: Rc<dyn Fn(ConnectRequest)>,
|
pub on_pair: Rc<dyn Fn(ConnectRequest)>,
|
||||||
pub on_library: Rc<dyn Fn(ConnectRequest)>,
|
pub on_library: Rc<dyn Fn(ConnectRequest)>,
|
||||||
@@ -549,17 +546,15 @@ fn saved_card(
|
|||||||
overlay.add_controller(right_click);
|
overlay.add_controller(right_click);
|
||||||
|
|
||||||
let on_connect = state.cbs.on_connect.clone();
|
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, fire a
|
||||||
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, route to
|
// magic packet before connecting — the connect's own retry/timeout gives a woken host time to
|
||||||
// the wake-and-wait flow (send a magic packet, poll mDNS until it's up — re-keying a new DHCP IP —
|
// come up. A host that's genuinely off/unreachable then fails the connect as before.
|
||||||
// then connect). Otherwise a plain connect. A host that's genuinely off then times out as before.
|
|
||||||
let wake_first = !online && !req.mac.is_empty();
|
let wake_first = !online && !req.mac.is_empty();
|
||||||
child.connect_activate(move |_| {
|
child.connect_activate(move |_| {
|
||||||
if wake_first {
|
if wake_first {
|
||||||
on_wake_connect(req.clone());
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
} else {
|
|
||||||
on_connect(req.clone());
|
|
||||||
}
|
}
|
||||||
|
on_connect(req.clone());
|
||||||
});
|
});
|
||||||
child
|
child
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -817,9 +817,6 @@ fn attach_keyboard(
|
|||||||
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
|
// 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 {
|
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||||
cap.release();
|
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);
|
stop_kb.store(true, Ordering::SeqCst);
|
||||||
return glib::Propagation::Stop;
|
return glib::Propagation::Stop;
|
||||||
}
|
}
|
||||||
@@ -1027,8 +1024,6 @@ fn spawn_disconnect_watch(
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
if disconnect_rx.recv().await.is_ok() {
|
if disconnect_rx.recv().await.is_ok() {
|
||||||
cap.release();
|
cap.release();
|
||||||
// Deliberate user exit (the controller escape chord) → QUIT_CLOSE_CODE, host skips linger.
|
|
||||||
cap.connector.disconnect_quit();
|
|
||||||
if window.is_fullscreen() {
|
if window.is_fullscreen() {
|
||||||
window.unfullscreen();
|
window.unfullscreen();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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
|
/// (the Apple TrustCardView format), far easier to compare against the host's log than
|
||||||
/// one 64-char run.
|
/// one 64-char run.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{AppCtx, Screen, Svc, Target};
|
use super::{AppCtx, Screen, Svc, Target};
|
||||||
use crate::discovery::DiscoveredHost;
|
|
||||||
use crate::session::{self, SessionEvent, SessionParams, Stats};
|
use crate::session::{self, SessionEvent, SessionParams, Stats};
|
||||||
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
||||||
use crate::video::DecoderPref;
|
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.
|
/// The plain "Connecting…" screen shown while the session worker handshakes. No hooks.
|
||||||
pub(crate) fn connecting_page(ctx: &Arc<AppCtx>, status: &str) -> Element {
|
pub(crate) fn connecting_page(ctx: &Arc<AppCtx>, status: &str) -> Element {
|
||||||
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
||||||
@@ -457,35 +365,3 @@ pub(crate) fn request_access_page(
|
|||||||
vec![cancel_btn.into()],
|
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()],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
|
//! 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.
|
//! 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::speed::SpeedState;
|
||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{Screen, Svc, Target};
|
use super::{Screen, Svc, Target};
|
||||||
@@ -386,14 +386,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
),
|
),
|
||||||
Some(menu),
|
Some(menu),
|
||||||
Some(Box::new(move || {
|
Some(Box::new(move || {
|
||||||
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
|
// Auto-wake an offline saved host before connecting; the connect's own
|
||||||
// the network (re-sending periodically) before dialing — a cold box boots far
|
// retry/timeout gives a woken host time to come up.
|
||||||
// slower than a connect will sit. An online host dials straight away.
|
|
||||||
if can_wake {
|
if can_wake {
|
||||||
wake_and_connect(&ctx2, target.clone(), &ss, &st);
|
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||||
} else {
|
|
||||||
initiate(&ctx2, target.clone(), &ss, &st);
|
|
||||||
}
|
}
|
||||||
|
initiate(&ctx2, target.clone(), &ss, &st)
|
||||||
})),
|
})),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,6 @@ pub(crate) enum Screen {
|
|||||||
/// The no-PIN "request access" wait: an identified connect is in flight, parked by the host
|
/// 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.
|
/// until the operator approves this device in its console. Cancelable.
|
||||||
RequestAccess,
|
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,
|
Stream,
|
||||||
Settings,
|
Settings,
|
||||||
/// Open-source / third-party license notices (reached from 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,
|
set_hover,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// connecting_page / request_access_page / waking_page / settings_page / licenses_page use
|
// connecting_page / request_access_page / settings_page / licenses_page use no hooks
|
||||||
// no hooks (they never touch `cx`), so calling them inline is sound.
|
// (they never touch `cx`), so calling them inline is sound.
|
||||||
Screen::Connecting => connect::connecting_page(ctx, &status),
|
Screen::Connecting => connect::connecting_page(ctx, &status),
|
||||||
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
Screen::RequestAccess => connect::request_access_page(ctx, &set_screen),
|
||||||
Screen::Waking => connect::waking_page(ctx, &set_screen),
|
|
||||||
Screen::Settings => settings::settings_page(
|
Screen::Settings => settings::settings_page(
|
||||||
ctx,
|
ctx,
|
||||||
&set_screen,
|
&set_screen,
|
||||||
|
|||||||
@@ -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.
|
// 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 {
|
if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift {
|
||||||
set_captured(st, false);
|
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);
|
st.stop.store(true, Ordering::SeqCst);
|
||||||
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
||||||
return LRESULT(1);
|
return LRESULT(1);
|
||||||
|
|||||||
@@ -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.
|
/// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
|
|||||||
@@ -582,17 +582,6 @@ impl NativeClient {
|
|||||||
self.frames_dropped.load(Ordering::Relaxed)
|
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
|
/// 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
|
/// [`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
|
/// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ mod qos;
|
|||||||
mod udp;
|
mod udp;
|
||||||
|
|
||||||
pub use loopback::{loopback_pair, LoopbackTransport};
|
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
|
/// 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`].
|
/// socket (the GameStream video sender) rather than going through [`UdpTransport`].
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -7,13 +7,11 @@
|
|||||||
//! [`set_media_qos`] DSCP-tags the latency-sensitive video/audio traffic (+ Linux `SO_PRIORITY`) so a
|
//! [`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
|
//! 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
|
//! 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
|
//! is **opt-in** (`PUNKTFUNK_DSCP=1`): DSCP can interact badly with some consumer ISPs/routers, and on
|
||||||
//! ties it to its experimental low-latency mode): 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
|
//! 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).
|
//! 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::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
|
/// 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
|
/// 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`]).
|
/// Whether DSCP/QoS marking is enabled. Default **on for Android**, **off elsewhere**: on Wi-Fi
|
||||||
/// Off unless an embedder opts in — on Wi-Fi, access points commonly map DSCP to WMM access
|
/// (where most Android clients live) access points commonly map DSCP to WMM access categories, so
|
||||||
/// categories (a real airtime-priority win), but wired paths rarely honour it and some bleach or
|
/// tagging the video/audio sockets can win real airtime priority against other traffic on the link;
|
||||||
/// reject marked packets, so it never turns on by itself.
|
/// on the wired paths the other clients use it's rarely honoured and some paths bleach or reject
|
||||||
static DSCP_DEFAULT: AtomicBool = AtomicBool::new(false);
|
/// 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).
|
||||||
/// 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.
|
|
||||||
pub(crate) fn dscp_enabled() -> bool {
|
pub(crate) fn dscp_enabled() -> bool {
|
||||||
match std::env::var("PUNKTFUNK_DSCP").as_deref() {
|
match std::env::var("PUNKTFUNK_DSCP").as_deref() {
|
||||||
Ok("1") | Ok("true") | Ok("on") => true,
|
Ok("1") | Ok("true") | Ok("on") => true,
|
||||||
Ok("0") | Ok("false") | Ok("off") => false,
|
Ok("0") | Ok("false") | Ok("off") => false,
|
||||||
_ => DSCP_DEFAULT.load(Ordering::Relaxed),
|
_ => cfg!(target_os = "android"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,12 +43,6 @@ pub struct PortalCapturer {
|
|||||||
/// True only while the PipeWire stream is `Streaming`. [`try_latest`](Self::try_latest) reads it
|
/// 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`).
|
/// to distinguish a static desktop (alive, no new buffers) from a dead source (left `Streaming`).
|
||||||
streaming: Arc<AtomicBool>,
|
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
|
/// 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
|
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
|
||||||
/// is `Streaming`.
|
/// is `Streaming`.
|
||||||
@@ -136,8 +130,6 @@ struct PwHandles {
|
|||||||
active: Arc<AtomicBool>,
|
active: Arc<AtomicBool>,
|
||||||
negotiated: Arc<AtomicBool>,
|
negotiated: Arc<AtomicBool>,
|
||||||
streaming: Arc<AtomicBool>,
|
streaming: Arc<AtomicBool>,
|
||||||
/// See [`PortalCapturer::broken`].
|
|
||||||
broken: Arc<AtomicBool>,
|
|
||||||
/// This capture will offer LINEAR-dmabuf-only for the VAAPI passthrough (see
|
/// This capture will offer LINEAR-dmabuf-only for the VAAPI passthrough (see
|
||||||
/// [`PortalCapturer::vaapi_dmabuf`]).
|
/// [`PortalCapturer::vaapi_dmabuf`]).
|
||||||
vaapi_dmabuf: bool,
|
vaapi_dmabuf: bool,
|
||||||
@@ -154,7 +146,6 @@ impl PwHandles {
|
|||||||
active: self.active,
|
active: self.active,
|
||||||
negotiated: self.negotiated,
|
negotiated: self.negotiated,
|
||||||
streaming: self.streaming,
|
streaming: self.streaming,
|
||||||
broken: self.broken,
|
|
||||||
stall_since: None,
|
stall_since: None,
|
||||||
vaapi_dmabuf: self.vaapi_dmabuf,
|
vaapi_dmabuf: self.vaapi_dmabuf,
|
||||||
node_id,
|
node_id,
|
||||||
@@ -187,8 +178,6 @@ fn spawn_pipewire(
|
|||||||
let negotiated_cb = negotiated.clone();
|
let negotiated_cb = negotiated.clone();
|
||||||
let streaming = Arc::new(AtomicBool::new(false));
|
let streaming = Arc::new(AtomicBool::new(false));
|
||||||
let streaming_cb = streaming.clone();
|
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
|
// 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
|
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
||||||
// inner `mod pipewire` shadows the crate name at this scope.
|
// inner `mod pipewire` shadows the crate name at this scope.
|
||||||
@@ -210,7 +199,6 @@ fn spawn_pipewire(
|
|||||||
active_cb,
|
active_cb,
|
||||||
negotiated_cb,
|
negotiated_cb,
|
||||||
streaming_cb,
|
streaming_cb,
|
||||||
broken_cb,
|
|
||||||
zerocopy,
|
zerocopy,
|
||||||
preferred,
|
preferred,
|
||||||
quit_rx,
|
quit_rx,
|
||||||
@@ -224,7 +212,6 @@ fn spawn_pipewire(
|
|||||||
active,
|
active,
|
||||||
negotiated,
|
negotiated,
|
||||||
streaming,
|
streaming,
|
||||||
broken,
|
|
||||||
vaapi_dmabuf,
|
vaapi_dmabuf,
|
||||||
quit: quit_tx,
|
quit: quit_tx,
|
||||||
join,
|
join,
|
||||||
@@ -233,36 +220,48 @@ fn spawn_pipewire(
|
|||||||
|
|
||||||
impl Capturer for PortalCapturer {
|
impl Capturer for PortalCapturer {
|
||||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||||
// First frame can lag behind format negotiation; later frames arrive at ~fps. Wait in
|
// First frame can lag behind format negotiation; later frames arrive at ~fps.
|
||||||
// short slices so a GPU-import poison (worker death) fails the capture within ~0.5 s
|
match self.frames.recv_timeout(Duration::from_secs(10)) {
|
||||||
// instead of sitting out the full first-frame budget.
|
Ok(frame) => Ok(frame),
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
Err(RecvTimeoutError::Timeout) => {
|
||||||
loop {
|
// Split the two black-screen root causes apart so the operator gets a cause, not
|
||||||
if self.broken.load(Ordering::Relaxed) {
|
// just a symptom: did the format negotiate (compositor produced no buffers) or
|
||||||
return Err(anyhow!(
|
// not (no acceptable format / node never emitted a param)?
|
||||||
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
if self.negotiated.load(Ordering::Relaxed) {
|
||||||
failed repeatedly — rebuilding capture",
|
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
|
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>> {
|
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
|
// Drain to the newest queued frame without blocking; `None` means the compositor
|
||||||
// hasn't produced a new frame since last call (static/idle desktop).
|
// hasn't produced a new frame since last call (static/idle desktop).
|
||||||
let mut latest = None;
|
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 {
|
impl Drop for PortalCapturer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// Stop the PipeWire loop and wait for the thread to unwind BEFORE the keepalive (virtual
|
// 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
|
/// `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.
|
/// switch). Read by [`PortalCapturer::try_latest`] to surface a sustained drop as a loss.
|
||||||
streaming: Arc<AtomicBool>,
|
streaming: Arc<AtomicBool>,
|
||||||
/// Poison flag (see [`PortalCapturer::broken`]): set here when the GPU import is
|
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer.
|
||||||
/// irrecoverably gone for this stream — the import worker died, or tiled imports failed
|
importer: Option<crate::zerocopy::EglImporter>,
|
||||||
/// [`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>,
|
|
||||||
/// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead
|
/// 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
|
/// of a CUDA import. Set when zero-copy is on, the EGL→CUDA importer is unavailable, and the
|
||||||
/// encoder backend is VAAPI (AMD/Intel).
|
/// encoder backend is VAAPI (AMD/Intel).
|
||||||
@@ -613,12 +561,6 @@ mod pipewire {
|
|||||||
dbg_log_n: u64,
|
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
|
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
|
||||||
/// pipeline must say why without flooding).
|
/// pipeline must say why without flooding).
|
||||||
fn warn_once(msg: &'static str) {
|
fn warn_once(msg: &'static str) {
|
||||||
@@ -872,11 +814,6 @@ mod pipewire {
|
|||||||
if !ud.active.load(Ordering::Relaxed) {
|
if !ud.active.load(Ordering::Relaxed) {
|
||||||
return;
|
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
|
// 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
|
// 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
|
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
|
||||||
@@ -1028,8 +965,6 @@ mod pipewire {
|
|||||||
};
|
};
|
||||||
match imported {
|
match imported {
|
||||||
Ok(devbuf) => {
|
Ok(devbuf) => {
|
||||||
ud.import_fail_streak = 0;
|
|
||||||
crate::zerocopy::note_gpu_import_ok();
|
|
||||||
static ONCE: std::sync::atomic::AtomicBool =
|
static ONCE: std::sync::atomic::AtomicBool =
|
||||||
std::sync::atomic::AtomicBool::new(true);
|
std::sync::atomic::AtomicBool::new(true);
|
||||||
if ONCE.swap(false, Ordering::Relaxed) {
|
if ONCE.swap(false, Ordering::Relaxed) {
|
||||||
@@ -1055,32 +990,12 @@ mod pipewire {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let dead = importer.dead();
|
// GPU import unavailable for this buffer kind (e.g. the
|
||||||
if dead {
|
// driver rejects LINEAR external-memory import). Disable
|
||||||
crate::zerocopy::note_gpu_import_death();
|
// the importer and fall through to the CPU mmap path —
|
||||||
}
|
// degraded, not dead.
|
||||||
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 {
|
|
||||||
tracing::warn!(error = %format!("{e:#}"),
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
streak = ud.import_fail_streak,
|
"dmabuf GPU import failed — falling back to the CPU copy path");
|
||||||
"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");
|
|
||||||
gpu_import_broken = true;
|
gpu_import_broken = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1223,7 +1138,6 @@ mod pipewire {
|
|||||||
active: Arc<AtomicBool>,
|
active: Arc<AtomicBool>,
|
||||||
negotiated: Arc<AtomicBool>,
|
negotiated: Arc<AtomicBool>,
|
||||||
streaming: Arc<AtomicBool>,
|
streaming: Arc<AtomicBool>,
|
||||||
broken: Arc<AtomicBool>,
|
|
||||||
zerocopy: bool,
|
zerocopy: bool,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
quit_rx: pw::channel::Receiver<()>,
|
quit_rx: pw::channel::Receiver<()>,
|
||||||
@@ -1251,40 +1165,26 @@ mod pipewire {
|
|||||||
.context("pw connect (default daemon)")?,
|
.context("pw connect (default daemon)")?,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the GPU importer up front — normally the ISOLATED worker process
|
// Build the EGL→CUDA importer up front; if it fails, log and fall back to the CPU path
|
||||||
// (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
|
|
||||||
// (we simply won't request dmabuf below). Skipped entirely when the encode backend is
|
// (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
|
// 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,
|
// 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
|
// succeed and produce CUDA payloads the VAAPI encoder must reject.
|
||||||
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
|
|
||||||
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
|
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
|
||||||
let mut importer = if zerocopy && !backend_is_vaapi {
|
let importer = if zerocopy && !backend_is_vaapi {
|
||||||
if crate::zerocopy::gpu_import_disabled() {
|
match crate::zerocopy::EglImporter::new() {
|
||||||
tracing::warn!(
|
|
||||||
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
match crate::zerocopy::Importer::new_for_capture() {
|
|
||||||
Ok(i) => Some(i),
|
Ok(i) => Some(i),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
|
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
// PUNKTFUNK_FORCE_SHM=1 forces the race-free download path (SHM, no dmabuf) — a manual
|
// PUNKTFUNK_FORCE_SHM=1 forces the race-free download path (SHM, no dmabuf) — required on
|
||||||
// escape hatch, mainly for Mutter+NVIDIA: that combo has no implicit dmabuf fence, so
|
// Mutter+NVIDIA where dmabuf capture has no working sync and shows stale frames. KWin/
|
||||||
// zero-copy capture can in principle race the compositor's render and show stale frames.
|
// gamescope don't need it (they blit into the buffer, so no read-before-render race).
|
||||||
// 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).
|
|
||||||
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
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
|
// 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).
|
// 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:
|
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||||
// radeonsi/iHD import it and any compositor can allocate it.
|
// radeonsi/iHD import it and any compositor can allocate it.
|
||||||
let mut modifiers = importer
|
let mut modifiers = importer
|
||||||
.as_mut()
|
.as_ref()
|
||||||
.map(|i| i.supported_modifiers(crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap()))
|
.map(|i| i.supported_modifiers(crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
|
||||||
@@ -1347,8 +1247,6 @@ mod pipewire {
|
|||||||
active,
|
active,
|
||||||
negotiated,
|
negotiated,
|
||||||
streaming,
|
streaming,
|
||||||
broken,
|
|
||||||
import_fail_streak: 0,
|
|
||||||
importer,
|
importer,
|
||||||
vaapi_passthrough,
|
vaapi_passthrough,
|
||||||
nv12: crate::zerocopy::nv12_enabled(),
|
nv12: crate::zerocopy::nv12_enabled(),
|
||||||
@@ -1402,13 +1300,6 @@ mod pipewire {
|
|||||||
}
|
}
|
||||||
if ud.info.parse(param).is_ok() {
|
if ud.info.parse(param).is_ok() {
|
||||||
ud.negotiated.store(true, Ordering::Relaxed);
|
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();
|
let sz = ud.info.size();
|
||||||
ud.format = map_format(ud.info.format());
|
ud.format = map_format(ud.info.format());
|
||||||
ud.modifier = ud.info.modifier();
|
ud.modifier = ud.info.modifier();
|
||||||
|
|||||||
@@ -25,12 +25,9 @@
|
|||||||
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
|
//! - **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`.
|
//! 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
|
//! `PUNKTFUNK_ZEROCOPY` note: this field uses **presence** semantics (`var_os(..).is_some()`) to match the
|
||||||
//! the per-vendor default in `encode/ffmpeg_win.rs::zerocopy_enabled` (AMF on — on-glass validated
|
//! Windows `encode/ffmpeg_win.rs` reader. The Linux `zerocopy` module keeps its own *truthy* parser
|
||||||
//! 2026-07-06; QSV off until validated on Intel glass); an explicit value forces it (`0|false|off|no`
|
//! (`1|true|yes|on`) — the two are independent features that share a name; do NOT conflate them.
|
||||||
//! = 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.
|
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
@@ -46,9 +43,8 @@ pub struct HostConfig {
|
|||||||
pub render_adapter: Option<String>,
|
pub render_adapter: Option<String>,
|
||||||
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
|
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
|
||||||
pub idd_depth: usize,
|
pub idd_depth: usize,
|
||||||
/// `PUNKTFUNK_ZEROCOPY` — Windows D3D11 zero-copy encode input override. `None` (unset) defers to
|
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
|
||||||
/// the per-vendor default (AMF on, QSV off — see module docs and `encode/ffmpeg_win.rs`).
|
pub zerocopy: bool,
|
||||||
pub zerocopy: Option<bool>,
|
|
||||||
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
||||||
pub ten_bit: bool,
|
pub ten_bit: bool,
|
||||||
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions). Honored only
|
/// `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")
|
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
.unwrap_or(2),
|
.unwrap_or(2),
|
||||||
zerocopy: val("PUNKTFUNK_ZEROCOPY").map(|s| {
|
zerocopy: flag("PUNKTFUNK_ZEROCOPY"),
|
||||||
!matches!(
|
|
||||||
s.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"0" | "false" | "off" | "no"
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
ten_bit: flag("PUNKTFUNK_10BIT"),
|
ten_bit: flag("PUNKTFUNK_10BIT"),
|
||||||
four_four_four: flag("PUNKTFUNK_444"),
|
four_four_four: flag("PUNKTFUNK_444"),
|
||||||
perf: flag("PUNKTFUNK_PERF"),
|
perf: flag("PUNKTFUNK_PERF"),
|
||||||
|
|||||||
@@ -194,15 +194,6 @@ pub trait Encoder: Send {
|
|||||||
}
|
}
|
||||||
/// Pull the next encoded AU if one is ready.
|
/// Pull the next encoded AU if one is ready.
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
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)
|
/// 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`.
|
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||||
fn flush(&mut self) -> Result<()>;
|
fn flush(&mut self) -> Result<()>;
|
||||||
@@ -379,9 +370,6 @@ impl Encoder for TrackedEncoder {
|
|||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||||
self.inner.poll()
|
self.inner.poll()
|
||||||
}
|
}
|
||||||
fn reset(&mut self) -> bool {
|
|
||||||
self.inner.reset()
|
|
||||||
}
|
|
||||||
fn flush(&mut self) -> Result<()> {
|
fn flush(&mut self) -> Result<()> {
|
||||||
self.inner.flush()
|
self.inner.flush()
|
||||||
}
|
}
|
||||||
@@ -546,40 +534,17 @@ fn open_video_backend(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WindowsBackend::Amf => {
|
backend @ (WindowsBackend::Amf | WindowsBackend::Qsv) => {
|
||||||
// AMD: the native AMF SDK encoder, unconditionally (design/native-amf-encoder.md
|
// AMD AMF / Intel QSV via libavcodec (the Windows analogue of the Linux VAAPI path).
|
||||||
// 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).
|
|
||||||
#[cfg(feature = "amf-qsv")]
|
#[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::FfmpegWinEncoder::open(
|
||||||
ffmpeg_win::WinVendor::Qsv,
|
vendor,
|
||||||
codec,
|
codec,
|
||||||
format,
|
format,
|
||||||
width,
|
width,
|
||||||
@@ -593,10 +558,11 @@ fn open_video_backend(
|
|||||||
}
|
}
|
||||||
#[cfg(not(feature = "amf-qsv"))]
|
#[cfg(not(feature = "amf-qsv"))]
|
||||||
{
|
{
|
||||||
|
let _ = backend;
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Intel (QSV) encode requested/detected but this host was built without \
|
"AMD/Intel (AMF/QSV) encode requested/detected but this host was built \
|
||||||
it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a FFMPEG_DIR \
|
without it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a \
|
||||||
with the QSV encoders at build time)"
|
FFMPEG_DIR with the AMF/QSV encoders at build time)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,13 +773,14 @@ pub fn can_encode_444(codec: Codec) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// AMD: native AMF never encodes 4:4:4 — VCN hardware limit, permanent, no probe
|
WindowsBackend::Amf | WindowsBackend::Qsv => {
|
||||||
// needed (design/native-amf-encoder.md §3.5, Phase 3).
|
|
||||||
WindowsBackend::Amf => false,
|
|
||||||
WindowsBackend::Qsv => {
|
|
||||||
#[cfg(feature = "amf-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"))]
|
#[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**
|
/// True if the active Windows backend is the libavcodec AMF/QSV path (so the codec advertisement
|
||||||
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
|
/// consults a real GPU probe rather than the NVENC static superset). Always false when the
|
||||||
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV still needs
|
/// `amf-qsv` feature is off — there's then no ffmpeg backend to probe.
|
||||||
/// 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).
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn windows_backend_is_probed() -> bool {
|
pub fn windows_backend_is_ffmpeg() -> bool {
|
||||||
match windows_resolved_backend() {
|
cfg!(feature = "amf-qsv")
|
||||||
WindowsBackend::Amf => true,
|
&& matches!(
|
||||||
WindowsBackend::Qsv => cfg!(feature = "amf-qsv"),
|
windows_resolved_backend(),
|
||||||
WindowsBackend::Nvenc | WindowsBackend::Software => false,
|
WindowsBackend::Amf | WindowsBackend::Qsv
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect the encode-GPU vendor from the **selected render adapter** ([`crate::gpu::selected_gpu`]:
|
/// 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,
|
/// Probe the active Windows AMF/QSV backend for its encodable codecs (opens a tiny encoder per
|
||||||
/// selected GPU)** — a web-console preference change re-probes on the newly selected adapter
|
/// codec; cached **per (backend, selected GPU)** — a web-console preference change re-probes on the
|
||||||
/// instead of serving the old GPU's answer for the process lifetime). Mirrors
|
/// newly selected adapter instead of serving the old GPU's answer for the process lifetime).
|
||||||
/// [`vaapi_codec_support`]; called only when [`windows_backend_is_probed`] is true. AV1 is narrow
|
/// Mirrors [`vaapi_codec_support`]; called only when [`windows_backend_is_ffmpeg`] is true. AV1 is
|
||||||
/// (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
|
/// narrow (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
|
||||||
///
|
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||||
/// 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")]
|
|
||||||
pub fn windows_codec_support() -> CodecSupport {
|
pub fn windows_codec_support() -> CodecSupport {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
|
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
|
||||||
let backend = windows_resolved_backend();
|
let vendor = match windows_resolved_backend() {
|
||||||
let key = format!("{backend:?}:{}", crate::gpu::selection_key());
|
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()));
|
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
if let Some(c) = cache.lock().unwrap().get(&key) {
|
if let Some(c) = cache.lock().unwrap().get(&key) {
|
||||||
return *c;
|
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 {
|
let caps = CodecSupport {
|
||||||
h264: probe_one(Codec::H264),
|
h264: ffmpeg_win::probe_can_encode(vendor, Codec::H264),
|
||||||
h265: probe_one(Codec::H265),
|
h265: ffmpeg_win::probe_can_encode(vendor, Codec::H265),
|
||||||
av1: probe_one(Codec::Av1),
|
av1: ffmpeg_win::probe_can_encode(vendor, Codec::Av1),
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
?backend,
|
backend = ?vendor,
|
||||||
h264 = caps.h264,
|
h264 = caps.h264,
|
||||||
h265 = caps.h265,
|
h265 = caps.h265,
|
||||||
av1 = caps.av1,
|
av1 = caps.av1,
|
||||||
@@ -979,14 +921,8 @@ pub fn windows_codec_support() -> CodecSupport {
|
|||||||
caps
|
caps
|
||||||
}
|
}
|
||||||
|
|
||||||
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, native AMF, AMF/QSV
|
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
|
||||||
// ffmpeg, software) and `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the
|
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
|
||||||
// `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;
|
|
||||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||||
#[path = "encode/windows/ffmpeg_win.rs"]
|
#[path = "encode/windows/ffmpeg_win.rs"]
|
||||||
mod ffmpeg_win;
|
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
|
//! AMD **AMF** and Intel **QSV** hardware encode on Windows via `ffmpeg-next` — the Windows
|
||||||
//! via `ffmpeg-next` — the Windows analogue of the Linux [`super::vaapi`] backend (one libavcodec
|
//! analogue of the Linux [`super::vaapi`] backend (one libavcodec backend per vendor, selected by
|
||||||
//! backend per vendor, selected by encoder name: `*_qsv` / `*_amf`). Sibling of the direct-SDK
|
//! encoder name: `*_amf` / `*_qsv`). This is the sibling of the direct-SDK [`super::nvenc`] path
|
||||||
//! [`super::nvenc`] path behind the shared [`Encoder`] trait.
|
//! behind the shared [`Encoder`] trait, selected in [`super::open_video`] (NVIDIA → NVENC,
|
||||||
//!
|
//! AMD → AMF, Intel → QSV).
|
||||||
//! **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.
|
|
||||||
//!
|
//!
|
||||||
//! The capturer hands a `FramePayload::D3d11` texture (NV12/P010 from the D3D11 video processor, or
|
//! 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
|
//! 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:
|
//! 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
|
//! 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
|
//! 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
|
//! GPU→CPU→GPU round-trip per frame — the robust path, and the only one that can be brought up
|
||||||
//! fallback when the zero-copy setup fails (it is the analogue of the VAAPI "CPU input" fallback).
|
//! without on-glass validation (it is the analogue of the VAAPI "CPU input" fallback).
|
||||||
//! * **Zero-copy D3D11** ([`ZeroCopyInner`], the AMF default; see [`zerocopy_enabled`]): wrap the
|
//! * **Zero-copy D3D11** ([`ZeroCopyInner`], `PUNKTFUNK_ZEROCOPY=1`): wrap the capturer's
|
||||||
//! capturer's `ID3D11Device` as an `AV_HWDEVICE_TYPE_D3D11VA` hwdevice (shared, *not* a second
|
//! `ID3D11Device` as an `AV_HWDEVICE_TYPE_D3D11VA` hwdevice (shared, *not* a second device — the
|
||||||
//! device — the capture textures are not shared-handle, so a different device couldn't read them),
|
//! capture textures are not shared-handle, so a different device couldn't read them), keep an
|
||||||
//! keep an FFmpeg D3D11 frames pool, `CopySubresourceRegion` the captured texture into a pooled
|
//! FFmpeg D3D11 frames pool, `CopySubresourceRegion` the captured texture into a pooled array
|
||||||
//! array slice (a GPU-local copy, like NVENC's CUDA path), then feed AMF `AV_PIX_FMT_D3D11`
|
//! slice (a GPU-local copy, like NVENC's CUDA path), then feed AMF `AV_PIX_FMT_D3D11` directly,
|
||||||
//! directly, or map the D3D11 frame to a derived QSV surface for QSV. If the hw setup fails to
|
//! or map the D3D11 frame to a derived QSV surface for QSV. If the hw setup fails to open, this
|
||||||
//! open, this falls back to the system-memory path for the session.
|
//! 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
|
//! **Status: compiles in CI; not yet on-glass validated** (no AMD/Intel Windows box in the lab as of
|
||||||
//! paths; zero-copy cut `submit_us` p50 2.8 ms → 0.26 ms) — zero-copy is the AMF default. QSV is
|
//! 2026-06-22). The system path is the conservative default; zero-copy is opt-in until validated.
|
||||||
//! still not on-glass validated (no Intel Windows box in the lab), so its zero-copy path stays
|
|
||||||
//! opt-in via `PUNKTFUNK_ZEROCOPY=1`.
|
|
||||||
//!
|
//!
|
||||||
//! Raw FFI: `ffmpeg-next` has no hwcontext wrappers for D3D11VA, so the hwdevice/hwframes calls go
|
//! 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
|
//! 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`
|
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated;
|
||||||
/// (`0|false|off|no` = off, anything else = on) overrides; unset defers to the per-vendor default:
|
/// the default is the robust system-memory readback path.
|
||||||
/// **on for AMF** — on-glass validated 2026-07-06 (Ryzen iGPU, 1080p120 HDR P010: `submit_us` p50
|
fn zerocopy_enabled() -> bool {
|
||||||
/// 2.8 ms → 0.26 ms vs readback) — and **off for QSV** until validated on Intel glass (the
|
crate::config::config().zerocopy
|
||||||
/// 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
/// 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
|
// Zero-copy D3D11 path (PUNKTFUNK_ZEROCOPY=1): share the capture device, pool D3D11 frames, copy
|
||||||
// device, pool D3D11 frames, copy the captured texture into a pooled slice, feed AMF directly /
|
// the captured texture into a pooled slice, feed AMF directly / map to QSV. Falls back to the
|
||||||
// map to QSV. Falls back to the system path if the hw setup fails to open.
|
// system path if the hw setup fails to open. Untested on glass — opt-in only for now.
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
struct D3d11Hw {
|
struct D3d11Hw {
|
||||||
@@ -1214,7 +1199,7 @@ impl FfmpegWinEncoder {
|
|||||||
}
|
}
|
||||||
self.inner = None;
|
self.inner = None;
|
||||||
self.bound_device = dev_raw;
|
self.bound_device = dev_raw;
|
||||||
let inner = if zerocopy_enabled(self.vendor) {
|
let inner = if zerocopy_enabled() {
|
||||||
match ZeroCopyInner::open(
|
match ZeroCopyInner::open(
|
||||||
self.vendor,
|
self.vendor,
|
||||||
self.codec,
|
self.codec,
|
||||||
@@ -1322,18 +1307,6 @@ impl Encoder for FfmpegWinEncoder {
|
|||||||
self.force_kf = true;
|
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`).
|
/// 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
|
/// 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
|
// 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
|
// old iGPU might lack HEVC). NVENC and the GPU-less software path keep the static superset.
|
||||||
// libavcodec build. NVENC and the GPU-less software path keep the static superset.
|
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||||
#[cfg(target_os = "windows")]
|
if crate::encode::windows_backend_is_ffmpeg() {
|
||||||
if crate::encode::windows_backend_is_probed() {
|
|
||||||
if let Some(m) = probed_mask(crate::encode::windows_codec_support()) {
|
if let Some(m) = probed_mask(crate::encode::windows_codec_support()) {
|
||||||
return m;
|
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,
|
/// 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
|
/// 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.
|
/// 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> {
|
fn probed_mask(caps: crate::encode::CodecSupport) -> Option<u32> {
|
||||||
use super::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC};
|
use super::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC};
|
||||||
let mut m = 0;
|
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:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,21 +90,6 @@ pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
|
|||||||
|
|
||||||
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
|
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
|
/// 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
|
/// 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
|
/// (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,
|
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
||||||
) -> CUresult,
|
) -> CUresult,
|
||||||
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> 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
|
// 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
|
// `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")
|
.get(b"cuExternalMemoryGetMappedBuffer\0")
|
||||||
.ok()?,
|
.ok()?,
|
||||||
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\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)
|
std::mem::forget(lib); // keep libcuda mapped for the fn pointers' lifetime (process)
|
||||||
Some(api)
|
Some(api)
|
||||||
@@ -372,28 +346,6 @@ unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUresult {
|
|||||||
None => CU_ERROR_NOT_LOADED,
|
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]
|
#[inline]
|
||||||
fn ck(r: CUresult, what: &str) -> Result<()> {
|
fn ck(r: CUresult, what: &str) -> Result<()> {
|
||||||
@@ -435,55 +387,6 @@ pub fn read_plane_to_host(
|
|||||||
Ok(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
|
/// 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.
|
/// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -773,7 +676,6 @@ impl BufferPool {
|
|||||||
height: self.height,
|
height: self.height,
|
||||||
uv: Some((uv_ptr, uv_pitch)),
|
uv: Some((uv_ptr, uv_pitch)),
|
||||||
pool: Some(self.inner.clone()),
|
pool: Some(self.inner.clone()),
|
||||||
remote_release: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let reuse = self.inner.lock().unwrap().free.pop();
|
let reuse = self.inner.lock().unwrap().free.pop();
|
||||||
@@ -788,7 +690,6 @@ impl BufferPool {
|
|||||||
height: self.height,
|
height: self.height,
|
||||||
uv: None,
|
uv: None,
|
||||||
pool: Some(self.inner.clone()),
|
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).
|
/// `None` for the default 4-byte RGB/BGRx path. When `Some`, [`ptr`] is the Y plane (1 byte/px).
|
||||||
pub uv: Option<(CUdeviceptr, usize)>,
|
pub uv: Option<(CUdeviceptr, usize)>,
|
||||||
pool: Option<Arc<Mutex<PoolInner>>>,
|
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 {
|
impl DeviceBuffer {
|
||||||
@@ -822,7 +719,6 @@ impl DeviceBuffer {
|
|||||||
height,
|
height,
|
||||||
uv: None,
|
uv: None,
|
||||||
pool: None,
|
pool: None,
|
||||||
remote_release: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,7 +733,6 @@ impl DeviceBuffer {
|
|||||||
height,
|
height,
|
||||||
uv: Some((uv_ptr, uv_pitch)),
|
uv: Some((uv_ptr, uv_pitch)),
|
||||||
pool: None,
|
pool: None,
|
||||||
remote_release: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,38 +740,10 @@ impl DeviceBuffer {
|
|||||||
pub fn is_nv12(&self) -> bool {
|
pub fn is_nv12(&self) -> bool {
|
||||||
self.uv.is_some()
|
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 {
|
impl Drop for DeviceBuffer {
|
||||||
fn drop(&mut self) {
|
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 {
|
if self.ptr == 0 {
|
||||||
return;
|
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 {
|
impl Drop for RegisteredTexture {
|
||||||
fn drop(&mut self) {
|
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 —
|
/// 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
|
/// 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.
|
/// 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 {
|
impl Drop for Nv12Blit {
|
||||||
fn drop(&mut self) {
|
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`
|
// 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
|
// 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
|
// `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);
|
// 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
|
// `[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
|
// 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,
|
// (libglvnd) to the driver for the current context. Each name is deleted exactly once.
|
||||||
// after its CUDA registration was released above.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
glDeleteTextures(1, &self.y_tex);
|
glDeleteTextures(1, &self.y_tex);
|
||||||
glDeleteTextures(1, &self.uv_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
|
/// The DRM format modifiers the NVIDIA EGL stack can import for `fourcc`, via
|
||||||
/// `eglQueryDmaBufModifiersEXT`. We advertise these to PipeWire so the compositor allocates
|
/// `eglQueryDmaBufModifiersEXT`. We advertise these to PipeWire so the compositor allocates
|
||||||
/// a dmabuf in a layout we can import. Empty on failure (caller falls back).
|
/// a dmabuf in a layout we can import. Empty on failure (caller falls back).
|
||||||
|
|||||||
@@ -10,14 +10,11 @@
|
|||||||
//! headless EGLDisplay + dmabuf→`EGLImage`→CUDA import). The encoder's CUDA-frame path lives in
|
//! 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`.
|
//! `encode/linux.rs`; the dmabuf negotiation lives in `capture/linux.rs`.
|
||||||
|
|
||||||
pub mod client;
|
|
||||||
pub mod cuda;
|
pub mod cuda;
|
||||||
pub mod egl;
|
pub mod egl;
|
||||||
pub mod proto;
|
|
||||||
pub mod vulkan;
|
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 cuda::DeviceBuffer;
|
||||||
pub use egl::{DmabufPlane, EglImporter};
|
pub use egl::{DmabufPlane, EglImporter};
|
||||||
@@ -51,21 +48,18 @@ pub fn vaapi_dmabuf_forced() -> bool {
|
|||||||
flag_opt("PUNKTFUNK_ZEROCOPY") == Some(true)
|
flag_opt("PUNKTFUNK_ZEROCOPY") == Some(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the zero-copy path is on. `PUNKTFUNK_ZEROCOPY` decides when set (truthy = on, else off).
|
/// Whether the zero-copy path is on. `PUNKTFUNK_ZEROCOPY` decides when set (truthy = on, else
|
||||||
/// **Unset defaults ON for both GPU backends** — the stock install gets the GPU dmabuf path, not
|
/// off). Unset defaults **on for the VAAPI (AMD/Intel) backend** — the stock AMD/Intel install
|
||||||
/// three full-frame CPU touches. This includes NVENC (previously opt-in): the EGL→CUDA (tiled) and
|
/// gets the GPU dmabuf path, not three full-frame CPU touches — unless a failed negotiation
|
||||||
/// Vulkan (LINEAR) imports now run in a per-capture worker subprocess
|
/// downgraded it ([`note_vaapi_dmabuf_failed`]); and **off for NVENC**, whose EGL→CUDA import
|
||||||
/// (`design/zerocopy-worker-isolation.md`), so a driver fault on a producer-invalidated dmabuf kills
|
/// stays opt-in (Mutter+NVIDIA has known dmabuf-capture races; see `PUNKTFUNK_FORCE_SHM`).
|
||||||
/// 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.
|
|
||||||
pub fn enabled() -> bool {
|
pub fn enabled() -> bool {
|
||||||
match flag_opt("PUNKTFUNK_ZEROCOPY") {
|
match flag_opt("PUNKTFUNK_ZEROCOPY") {
|
||||||
Some(v) => v,
|
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)
|
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"`).
|
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
|
||||||
const fn fourcc(c: &[u8; 4]) -> u32 {
|
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)
|
(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})");
|
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(())
|
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,
|
/// Bridge one LINEAR dmabuf frame into a pooled CUDA buffer: GPU copy dmabuf→exportable,
|
||||||
/// then pitched CUDA copy exportable→`pool` buffer.
|
/// then pitched CUDA copy exportable→`pool` buffer.
|
||||||
pub fn import_linear(
|
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",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -181,11 +181,6 @@ fn real_main() -> Result<()> {
|
|||||||
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Some("zerocopy-probe") => zerocopy::probe(),
|
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
|
// 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
|
// 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.
|
// `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.
|
||||||
|
|||||||
@@ -161,8 +161,6 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(get_display_state))
|
.routes(routes!(get_display_state))
|
||||||
.routes(routes!(release_display))
|
.routes(routes!(release_display))
|
||||||
.routes(routes!(set_display_layout))
|
.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_status))
|
||||||
.routes(routes!(get_local_summary))
|
.routes(routes!(get_local_summary))
|
||||||
.routes(routes!(list_paired_clients))
|
.routes(routes!(list_paired_clients))
|
||||||
@@ -995,10 +993,6 @@ struct DisplaySettingsState {
|
|||||||
effective: crate::vdisplay::policy::EffectivePolicy,
|
effective: crate::vdisplay::policy::EffectivePolicy,
|
||||||
/// Every named preset and what it expands to (for the picker's preview).
|
/// Every named preset and what it expands to (for the picker's preview).
|
||||||
presets: Vec<PresetInfo>,
|
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 +
|
/// 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
|
/// 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
|
/// 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,
|
settings,
|
||||||
configured,
|
configured,
|
||||||
presets,
|
presets,
|
||||||
custom_presets: policy::load_custom_presets(),
|
|
||||||
enforced: vec![
|
enforced: vec![
|
||||||
"keep_alive".into(),
|
"keep_alive".into(),
|
||||||
"topology".into(),
|
"topology".into(),
|
||||||
@@ -1273,109 +1266,6 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
|
|||||||
Json(display_settings_state()).into_response()
|
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
|
/// Live host status
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
|
|||||||
@@ -3198,18 +3198,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
let mut cur_mode = mode;
|
let mut cur_mode = mode;
|
||||||
const MAX_CAPTURE_REBUILDS: u32 = 5;
|
const MAX_CAPTURE_REBUILDS: u32 = 5;
|
||||||
let mut capture_rebuilds: u32 = 0;
|
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).
|
// 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;
|
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
|
// Frames submitted to NVENC but not yet polled (wire pts, submit stamp, pacing deadline). With a
|
||||||
@@ -3295,11 +3283,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
vd = new_vd;
|
vd = new_vd;
|
||||||
compositor = sw.compositor;
|
compositor = sw.compositor;
|
||||||
next = std::time::Instant::now();
|
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!(
|
tracing::info!(
|
||||||
compositor = compositor.id(),
|
compositor = compositor.id(),
|
||||||
"session switch — backend rebuilt, stream continues"
|
"session switch — backend rebuilt, stream continues"
|
||||||
@@ -3334,11 +3317,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
(capturer, enc, frame, interval, cur_node_id) = next_pipe;
|
(capturer, enc, frame, interval, cur_node_id) = next_pipe;
|
||||||
cur_mode = new_mode;
|
cur_mode = new_mode;
|
||||||
next = std::time::Instant::now();
|
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) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %format!("{e:#}"), ?new_mode,
|
tracing::error!(error = %format!("{e:#}"), ?new_mode,
|
||||||
@@ -3409,11 +3387,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
// library instead of surfacing a failure — rather than the capture-loss rebuild + 40 s
|
// 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
|
// timeout. Gated to the dedicated bare-spawn launch (`launch_is_nested`), so a normal
|
||||||
// Bazzite/desktop capture loss still rebuilds in place.
|
// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
if launch.is_some()
|
if launch.is_some()
|
||||||
&& crate::vdisplay::launch_is_nested(compositor)
|
&& crate::vdisplay::launch_is_nested(compositor)
|
||||||
@@ -3507,12 +3480,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
cur_node_id = new_node_id;
|
cur_node_id = new_node_id;
|
||||||
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
|
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
|
||||||
next = std::time::Instant::now();
|
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!(
|
tracing::info!(
|
||||||
compositor = compositor.id(),
|
compositor = compositor.id(),
|
||||||
"capture loss: pipeline rebuilt — stream resumes"
|
"capture loss: pipeline rebuilt — stream resumes"
|
||||||
@@ -3579,28 +3546,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
st_queue.push(queue_us);
|
st_queue.push(queue_us);
|
||||||
}
|
}
|
||||||
let t_submit = std::time::Instant::now();
|
let t_submit = std::time::Instant::now();
|
||||||
if let Err(e) = enc.submit(&frame) {
|
enc.submit(&frame).context("encoder submit")?;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
let submit_us = if measure {
|
let submit_us = if measure {
|
||||||
t_submit.elapsed().as_micros() as u32
|
t_submit.elapsed().as_micros() as u32
|
||||||
} else {
|
} else {
|
||||||
@@ -3618,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
|
// 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()`.
|
// the oldest submitted frame's AU — matching `inflight.pop_front()`.
|
||||||
let mut send_gone = false;
|
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 {
|
while inflight.len() >= depth {
|
||||||
let t_wait = std::time::Instant::now();
|
let t_wait = std::time::Instant::now();
|
||||||
let polled = enc.poll();
|
let polled = enc.poll().context("encoder poll")?;
|
||||||
let wait_us = if measure {
|
let wait_us = if measure {
|
||||||
t_wait.elapsed().as_micros() as u32
|
t_wait.elapsed().as_micros() as u32
|
||||||
} else {
|
} else {
|
||||||
@@ -3633,20 +3576,9 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
st_wait.push(wait_us);
|
st_wait.push(wait_us);
|
||||||
}
|
}
|
||||||
let au = match polled {
|
let au = match polled {
|
||||||
Ok(Some(au)) => au,
|
Some(au) => au,
|
||||||
// No AU ready for a submitted frame. Routine on the non-blocking backends (the
|
None => break, // no AU ready for a submitted frame (shouldn't happen — poll blocks)
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
// 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 (cap_ns, sub_ns, deadline) = inflight.pop_front().expect("inflight non-empty");
|
||||||
let flags = if au.keyframe {
|
let flags = if au.keyframe {
|
||||||
(FLAG_PIC | FLAG_SOF) as u32
|
(FLAG_PIC | FLAG_SOF) as u32
|
||||||
@@ -3687,40 +3619,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
if send_gone {
|
if send_gone {
|
||||||
break;
|
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()) {
|
match next.checked_duration_since(std::time::Instant::now()) {
|
||||||
Some(d) => std::thread::sleep(d),
|
Some(d) => std::thread::sleep(d),
|
||||||
None => next = std::time::Instant::now(),
|
None => next = std::time::Instant::now(),
|
||||||
@@ -3876,24 +3774,6 @@ fn is_permanent_build_error(chain: &str) -> bool {
|
|||||||
PERMANENT.iter().any(|p| lower.contains(p))
|
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(
|
fn build_pipeline(
|
||||||
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
|
|||||||
@@ -619,6 +619,11 @@ pub fn apply_session_env(active: &ActiveSession) {
|
|||||||
if let Some(d) = &e.xdg_current_desktop {
|
if let Some(d) = &e.xdg_current_desktop {
|
||||||
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
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
|
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||||
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
// [`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
|
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
||||||
|
|||||||
@@ -223,35 +223,19 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
|
/// 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
|
/// 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.
|
/// 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() {
|
if outputs.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Enable FIRST, as a standalone apply — a bare `output.X.enable` always succeeds, so a physical
|
let args: Vec<String> = outputs
|
||||||
// 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
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, _)| format!("output.{name}.enable"))
|
.map(|o| format!("output.{o}.enable"))
|
||||||
.collect();
|
.collect();
|
||||||
let _ = std::process::Command::new("kscreen-doctor")
|
let _ = std::process::Command::new("kscreen-doctor")
|
||||||
.args(&enable_args)
|
.args(&args)
|
||||||
.status();
|
.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));
|
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
|
/// 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.
|
/// recognised by this prefix, so we never have to thread the live set through the backend.
|
||||||
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
||||||
|
|
||||||
/// The current mode of an output as a kscreen-doctor mode setter, from its `-j` entry — preferring
|
/// Names of currently-ENABLED outputs that are **not managed by us** — the headless session's
|
||||||
/// the human `WxH@Hz` form (survives a mode-id re-enumeration across disable→enable) and falling back
|
/// bootstrap output(s) + any physical monitor, i.e. exactly what `exclusive` must disable.
|
||||||
/// 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).
|
|
||||||
/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this
|
/// **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
|
/// 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`]).
|
/// 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")
|
let out = match std::process::Command::new("kscreen-doctor")
|
||||||
.arg("-j")
|
.arg("-j")
|
||||||
.output()
|
.output()
|
||||||
@@ -392,15 +349,9 @@ fn other_enabled_outputs() -> Vec<(String, String)> {
|
|||||||
.map(|outs| {
|
.map(|outs| {
|
||||||
outs.iter()
|
outs.iter()
|
||||||
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
|
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
|
||||||
.filter_map(|o| {
|
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
|
||||||
let name = o.get("name").and_then(|n| n.as_str())?;
|
.filter(|n| !n.starts_with(MANAGED_PREFIX))
|
||||||
(!name.starts_with(MANAGED_PREFIX)).then(|| {
|
.map(String::from)
|
||||||
(
|
|
||||||
name.to_string(),
|
|
||||||
output_current_mode_spec(o).unwrap_or_default(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.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 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
|
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
|
||||||
/// showing only the wallpaper) rather than failing the session.
|
/// 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 ours = format!("Virtual-{name}");
|
||||||
let kscreen = |args: &[String]| {
|
let kscreen = |args: &[String]| {
|
||||||
std::process::Command::new("kscreen-doctor")
|
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
|
// 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).
|
// 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();
|
let others = other_enabled_outputs();
|
||||||
if !others.is_empty() {
|
if !others.is_empty() {
|
||||||
let args: Vec<String> = others
|
let args: Vec<String> = others
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(o, _mode)| format!("output.{o}.disable"))
|
.map(|o| format!("output.{o}.disable"))
|
||||||
.collect();
|
.collect();
|
||||||
let _ = kscreen(&args);
|
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)))
|
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)`.
|
/// The current (else preferred, else first) mode of `connector` → (mode_id, width, height).
|
||||||
fn current_mode_full(state: &CurrentState, connector: &str) -> Option<(String, i32, i32, f64)> {
|
fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i32)> {
|
||||||
let mon = state.1.iter().find(|m| m.0 .0 == connector)?;
|
let mon = state.1.iter().find(|m| m.0 .0 == connector)?;
|
||||||
let pick = mon
|
let pick = mon
|
||||||
.1
|
.1
|
||||||
@@ -421,83 +421,7 @@ fn current_mode_full(state: &CurrentState, connector: &str) -> Option<(String, i
|
|||||||
.find(|md| mode_flag(md, "is-current"))
|
.find(|md| mode_flag(md, "is-current"))
|
||||||
.or_else(|| mon.1.iter().find(|md| mode_flag(md, "is-preferred")))
|
.or_else(|| mon.1.iter().find(|md| mode_flag(md, "is-preferred")))
|
||||||
.or_else(|| mon.1.first())?;
|
.or_else(|| mon.1.first())?;
|
||||||
Some((pick.0.clone(), pick.1, pick.2, pick.3))
|
Some((pick.0.clone(), pick.1, pick.2))
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait for the virtual output to appear in DisplayConfig (its size follows PipeWire negotiation,
|
/// 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 {
|
let config = if exclusive {
|
||||||
build_exclusive_config(&vconn, &vmode)
|
build_exclusive_config(&vconn, &vmode)
|
||||||
} else {
|
} 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
|
let _: () = dc
|
||||||
.call(
|
.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
|
/// **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**
|
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its current mode). So
|
||||||
/// mode). So the shell + new windows land on the streamed surface, but the operator's physical
|
/// the shell + new windows land on the streamed surface, but the operator's physical screen stays
|
||||||
/// screen stays on **at its real refresh**. On a headless host (no physicals) this is identical to
|
/// on. On a headless host (no physicals) this is identical to [`build_exclusive_config`].
|
||||||
/// [`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`).
|
|
||||||
///
|
///
|
||||||
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
|
/// *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.
|
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
|
||||||
fn build_primary_keeping_physicals(
|
fn build_primary_keeping_physicals(
|
||||||
pre: &CurrentState,
|
|
||||||
state: &CurrentState,
|
state: &CurrentState,
|
||||||
vconn: &str,
|
vconn: &str,
|
||||||
vmode: &str,
|
vmode: &str,
|
||||||
@@ -608,15 +525,15 @@ fn build_primary_keeping_physicals(
|
|||||||
true,
|
true,
|
||||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
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
|
// Append each physical (non-virtual) connector that has a usable current mode, to the right of
|
||||||
// virtual output, as a non-primary secondary — at its PRE-connect mode (real refresh preserved).
|
// the virtual output, as a non-primary secondary.
|
||||||
let mut x = virt_width.max(0);
|
let mut x = virt_width.max(0);
|
||||||
for mon in &state.1 {
|
for mon in &state.1 {
|
||||||
let conn = &mon.0 .0;
|
let conn = &mon.0 .0;
|
||||||
if conn == vconn {
|
if conn == vconn {
|
||||||
continue;
|
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((
|
logicals.push((
|
||||||
x,
|
x,
|
||||||
0,
|
0,
|
||||||
@@ -630,84 +547,3 @@ fn build_primary_keeping_physicals(
|
|||||||
}
|
}
|
||||||
logicals
|
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))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,11 +23,10 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn keep_alive_serializes_tagged_on_mode() {
|
fn keep_alive_serializes_tagged_on_mode() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ use windows::Win32::System::Threading::{
|
|||||||
|
|
||||||
use super::{DisplayOwnership, Mode, VirtualOutput};
|
use super::{DisplayOwnership, Mode, VirtualOutput};
|
||||||
use crate::win_display::{
|
use crate::win_display::{
|
||||||
count_other_active, force_extend_topology, isolate_displays_ccd, resolve_gdi_name,
|
force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd,
|
||||||
restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig,
|
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
|
/// 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) };
|
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
||||||
}
|
}
|
||||||
Topology::Primary => {
|
Topology::Primary => {
|
||||||
// On a headless box the IDD auto-activates as the SOLE display, so a physical
|
// The IDD auto-activates as the SOLE display on a headless box, so the
|
||||||
// (if present) is deactivated and QueryDisplayConfig sees only the virtual —
|
// physical (if present) is deactivated and QueryDisplayConfig sees only the
|
||||||
// force EXTEND to (re)activate every connected display alongside the virtual,
|
// virtual. Force EXTEND first to (re)activate every CONNECTED display
|
||||||
// THEN reposition to make the virtual primary. BUT on a box whose physical is
|
// alongside the virtual, THEN reposition to make the virtual primary — so the
|
||||||
// ALREADY active (the IDD came up extended beside it — the common desktop case),
|
// physical stays active. (The bring-up above only force-EXTENDs when the
|
||||||
// that physical is already lit at its real mode; re-applying the bare
|
// virtual FAILS to auto-resolve; here it resolved, so we do it explicitly.)
|
||||||
// `SDC_TOPOLOGY_EXTEND` preset would only re-pull each display's mode from the
|
// SAFETY: `force_extend_topology` drives the CCD topology FFI (no args, no borrowed
|
||||||
// persistence DB, RESETTING a 120 Hz panel to 60 Hz. So force-EXTEND only when the
|
// memory), under the `state` lock — the sole topology mutator.
|
||||||
// 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.
|
|
||||||
unsafe { force_extend_topology() };
|
unsafe { force_extend_topology() };
|
||||||
thread::sleep(Duration::from_millis(300));
|
thread::sleep(Duration::from_millis(300));
|
||||||
}
|
|
||||||
// SAFETY: `set_virtual_primary_ccd` takes the `Copy` target id by value and returns
|
// 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.
|
// an owned `SavedConfig` (no borrowed memory crosses), under the `state` lock.
|
||||||
ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) };
|
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
|
/// 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
|
/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually took.
|
||||||
/// took, and (in the `primary` topology) to detect a physical that is ALREADY active so we can skip a
|
unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
|
||||||
/// force-EXTEND that would reset its refresh.
|
|
||||||
pub(crate) unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
|
|
||||||
let (paths, _) = query_active_config()?;
|
let (paths, _) = query_active_config()?;
|
||||||
Some(
|
Some(
|
||||||
paths
|
paths
|
||||||
|
|||||||
@@ -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 ~1–2 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 ~3–5 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 20–40× 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 ~1–5 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.7–2.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.2–5.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,15 +1,6 @@
|
|||||||
# Zero-copy capture hardening — issue handoff
|
# Zero-copy capture hardening — issue handoff
|
||||||
|
|
||||||
> **Status: FIXED + validated (2026-07-06).** The fix is implemented and on-glass validated — see
|
> **Status: HANDOFF — issue description only (2026-07-06).** This document describes a reproduced
|
||||||
> [`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
|
|
||||||
> host **SIGSEGV** in the Linux zero-copy capture path. It deliberately does **not** prescribe a fix —
|
> 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 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.
|
> the "Considerations / open questions" section frames the solution space without committing to one.
|
||||||
|
|||||||
@@ -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 1–2 **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.
|
|
||||||
@@ -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
|
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
|
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
|
```sh
|
||||||
XDG_RUNTIME_DIR=/run/user/1000
|
XDG_RUNTIME_DIR=/run/user/1000
|
||||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
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)
|
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session (see below)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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_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_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. |
|
| `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. |
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ WAYLAND_DISPLAY=wayland-0
|
|||||||
XDG_CURRENT_DESKTOP=GNOME
|
XDG_CURRENT_DESKTOP=GNOME
|
||||||
PUNKTFUNK_COMPOSITOR=mutter
|
PUNKTFUNK_COMPOSITOR=mutter
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
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
|
PUNKTFUNK_INPUT_BACKEND=libei
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ WAYLAND_DISPLAY=wayland-0
|
|||||||
XDG_CURRENT_DESKTOP=KDE
|
XDG_CURRENT_DESKTOP=KDE
|
||||||
PUNKTFUNK_COMPOSITOR=kwin
|
PUNKTFUNK_COMPOSITOR=kwin
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
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
|
PUNKTFUNK_INPUT_BACKEND=libei
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ these in `~/.config/punktfunk/host.env`:
|
|||||||
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, hyprland
|
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, hyprland
|
||||||
PUNKTFUNK_INPUT_BACKEND=wlr
|
PUNKTFUNK_INPUT_BACKEND=wlr
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
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.
|
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. |
|
| **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. |
|
| **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
|
## Options reference
|
||||||
|
|
||||||
Choose **Custom** in the console to set these directly.
|
Choose **Custom** in the console to set these directly.
|
||||||
|
|||||||
@@ -1501,18 +1501,6 @@ PunktfunkStatus punktfunk_connection_probe_result(const PunktfunkConnection *c,
|
|||||||
PunktfunkProbeResult *out);
|
PunktfunkProbeResult *out);
|
||||||
#endif
|
#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)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -242,9 +242,8 @@ PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui
|
|||||||
# gamescope hosts its own EIS input socket — input lands in the nested session.
|
# gamescope hosts its own EIS input socket — input lands in the nested session.
|
||||||
PUNKTFUNK_INPUT_BACKEND=gamescope
|
PUNKTFUNK_INPUT_BACKEND=gamescope
|
||||||
|
|
||||||
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC) is ON by default and auto-falls back to CPU if
|
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC). Auto-falls back to CPU if unavailable.
|
||||||
# unavailable. No need to set it. Set to 0 only to force the CPU path.
|
PUNKTFUNK_ZEROCOPY=1
|
||||||
# PUNKTFUNK_ZEROCOPY=0
|
|
||||||
|
|
||||||
#RUST_LOG=info
|
#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_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_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_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. |
|
| `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
|
**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
|
after an `rpm-ostree`/`bootc` update, confirm the NVIDIA driver still loads (`nvidia-smi`) before
|
||||||
blaming punktfunk.
|
blaming punktfunk.
|
||||||
|
|
||||||
- **Zero-copy falls back to CPU.** The zero-copy path (on by default) needs working EGL/CUDA from the
|
- **`PUNKTFUNK_ZEROCOPY=1` but it falls back to CPU.** The zero-copy path needs working EGL/CUDA from
|
||||||
NVIDIA driver. The code falls back to CPU automatically; check the log for the fallback line and
|
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.
|
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
|
- **Wrong UID in `host.env`.** `XDG_RUNTIME_DIR=/run/user/1000` and the bus path assume UID 1000. Run
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
|||||||
|
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
|
|
||||||
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC) is ON by default and auto-falls back to CPU if
|
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC). Auto-falls back to CPU if unavailable.
|
||||||
# unavailable. No need to set it. Set to 0 only to force the CPU path.
|
PUNKTFUNK_ZEROCOPY=1
|
||||||
# PUNKTFUNK_ZEROCOPY=0
|
|
||||||
|
|
||||||
#RUST_LOG=info
|
#RUST_LOG=info
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ WAYLAND_DISPLAY=wayland-kde
|
|||||||
XDG_CURRENT_DESKTOP=KDE
|
XDG_CURRENT_DESKTOP=KDE
|
||||||
PUNKTFUNK_COMPOSITOR=kwin
|
PUNKTFUNK_COMPOSITOR=kwin
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
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
|
PUNKTFUNK_INPUT_BACKEND=libei
|
||||||
# UDP Generic Segmentation Offload on the send path: coalesce a frame's equal-size packets into
|
# 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
|
# kernel super-buffers (one sendmsg per ~64 packets instead of one per packet) — the dominant
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
# bash scripts/headless/run-headless-kde.sh [WxH] # default 1920x1080
|
# bash scripts/headless/run-headless-kde.sh [WxH] # default 1920x1080
|
||||||
#
|
#
|
||||||
# Then in another shell:
|
# Then in another shell:
|
||||||
# WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \
|
# WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_ZEROCOPY=1 \
|
||||||
# punktfunk-host punktfunk1-host --source virtual --seconds 14400 # zero-copy is on by default
|
# punktfunk-host punktfunk1-host --source virtual --seconds 14400
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
RES="${1:-1920x1080}"
|
RES="${1:-1920x1080}"
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ XDG_CURRENT_DESKTOP=KDE
|
|||||||
# resolution+refresh (the flagship mode); `portal` captures an existing monitor.
|
# resolution+refresh (the flagship mode); `portal` captures an existing monitor.
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
|
|
||||||
# GPU zero-copy capture (dmabuf → CUDA → NVENC / VAAPI / Vulkan) is ON by default and falls back to
|
# GPU zero-copy capture (EGL/Vulkan → CUDA → NVENC). Falls back to CPU automatically.
|
||||||
# CPU automatically. No need to set it. Set to 0 only to force the CPU path.
|
PUNKTFUNK_ZEROCOPY=1
|
||||||
# PUNKTFUNK_ZEROCOPY=0
|
|
||||||
|
|
||||||
# --- Bazzite / SteamOS-like host: host-managed Steam-Deck-UI session -----------------------
|
# --- 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
|
# The host LAUNCHES gamescope-session-plus headless AT THE CLIENT'S mode (so games see the
|
||||||
|
|||||||
@@ -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_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_auto_row": "Automatisch (nebeneinander)",
|
||||||
"display_layout_manual": "Manuell",
|
"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_title": "Gekoppelte Geräte",
|
||||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -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_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_auto_row": "Auto (side by side)",
|
||||||
"display_layout_manual": "Manual",
|
"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_title": "Paired clients",
|
||||||
"clients_empty": "No paired clients yet.",
|
"clients_empty": "No paired clients yet.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@unom/ui/button";
|
import { Button } from "@unom/ui/button";
|
||||||
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { type FC, type ReactNode, useEffect, useState } from "react";
|
||||||
import { type FC, type MouseEvent, type ReactNode, useEffect, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
getGetDisplayStateQueryKey,
|
getGetDisplayStateQueryKey,
|
||||||
getGetDisplaySettingsQueryKey,
|
getGetDisplaySettingsQueryKey,
|
||||||
useCreateCustomPreset,
|
|
||||||
useDeleteCustomPreset,
|
|
||||||
useGetDisplaySettings,
|
useGetDisplaySettings,
|
||||||
useGetDisplayState,
|
useGetDisplayState,
|
||||||
useReleaseDisplay,
|
useReleaseDisplay,
|
||||||
useSetDisplayLayout,
|
useSetDisplayLayout,
|
||||||
useSetDisplaySettings,
|
useSetDisplaySettings,
|
||||||
useUpdateCustomPreset,
|
|
||||||
} from "@/api/gen/display/display";
|
} from "@/api/gen/display/display";
|
||||||
import type {
|
import type {
|
||||||
ApiDisplayInfo,
|
ApiDisplayInfo,
|
||||||
CustomPreset,
|
|
||||||
DisplayPolicy,
|
DisplayPolicy,
|
||||||
EffectivePolicy,
|
EffectivePolicy,
|
||||||
GameSession,
|
GameSession,
|
||||||
@@ -80,7 +75,6 @@ export const DisplaySection: FC = () => {
|
|||||||
draft={draft}
|
draft={draft}
|
||||||
setDraft={setDraft}
|
setDraft={setDraft}
|
||||||
presets={q.data.presets}
|
presets={q.data.presets}
|
||||||
customPresets={q.data.custom_presets}
|
|
||||||
apply={apply}
|
apply={apply}
|
||||||
busy={save.isPending}
|
busy={save.isPending}
|
||||||
error={apiErrorMessage(save.error)}
|
error={apiErrorMessage(save.error)}
|
||||||
@@ -115,23 +109,10 @@ const DisplayForm: FC<{
|
|||||||
draft: DisplayPolicy;
|
draft: DisplayPolicy;
|
||||||
setDraft: (p: DisplayPolicy) => void;
|
setDraft: (p: DisplayPolicy) => void;
|
||||||
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
||||||
customPresets: CustomPreset[];
|
|
||||||
apply: (p: DisplayPolicy) => void;
|
apply: (p: DisplayPolicy) => void;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> = ({ draft, setDraft, presets, customPresets, apply, busy, error }) => {
|
}> = ({ draft, setDraft, presets, 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,
|
|
||||||
);
|
|
||||||
|
|
||||||
const preset: Preset = draft.preset ?? "custom";
|
const preset: Preset = draft.preset ?? "custom";
|
||||||
const isCustom = 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;
|
const ka = customFields.keep_alive;
|
||||||
// The duration value, remembered across the Off/Keep toggle so switching back restores it.
|
// The duration value, remembered across the Off/Keep toggle so switching back restores it.
|
||||||
const [keepSecs, setKeepSecs] = useState(ka.mode === "duration" ? ka.seconds : 300);
|
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 p = presets.find((x) => x.id === id);
|
||||||
const fields = id === "custom" ? undefined : p?.fields;
|
const fields = id === "custom" ? undefined : p?.fields;
|
||||||
const summary = id === "custom" ? m.display_custom_desc() : p?.summary;
|
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
|
const selected = preset === id;
|
||||||
// value-matches a saved preset, that preset's card owns the "current" ring instead.
|
|
||||||
const selected = preset === id && !(id === "custom" && anyCustomSelected);
|
|
||||||
const soon = DISABLED_PRESETS.has(id);
|
const soon = DISABLED_PRESETS.has(id);
|
||||||
const disabled = busy || soon;
|
const disabled = busy || soon;
|
||||||
const pick = () => {
|
const pick = () => {
|
||||||
@@ -292,44 +221,6 @@ const DisplayForm: FC<{
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Custom: every option by hand */}
|
||||||
{isCustom && (
|
{isCustom && (
|
||||||
<div className="space-y-6 rounded-lg border p-5">
|
<div className="space-y-6 rounded-lg border p-5">
|
||||||
@@ -521,95 +412,6 @@ const Choice: FC<{
|
|||||||
</Field>
|
</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
|
* 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).
|
* 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,
|
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). */
|
/** 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 tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
|
||||||
const fn = key == null ? undefined : map[key];
|
const fn = key == null ? undefined : map[key];
|
||||||
|
|||||||
Reference in New Issue
Block a user