Compare commits
16
Commits
@@ -0,0 +1,95 @@
|
||||
# Move a versionCode that is ALREADY on Google Play between tracks — no rebuild.
|
||||
#
|
||||
# Why this is separate from android.yml: promotion must not rebuild. A rebuild produces a fresh
|
||||
# versionCode (github.run_number) from possibly-newer sources, so it ships something nobody tested;
|
||||
# promoting assigns the byte-identical artifact the testers already ran. Bolting this onto
|
||||
# android.yml would mean an `if:` on all ten of its build steps.
|
||||
#
|
||||
# What it is for:
|
||||
# * promote a tested build up a track (alpha -> production)
|
||||
# * roll production back by re-pointing it at an older versionCode (to_track=production,
|
||||
# version_code=<the good one>, from_track blank)
|
||||
# * halt a rollout (status=halted)
|
||||
#
|
||||
# Defaults are deliberately the safe ones: dry_run starts TRUE, so a mis-typed versionCode
|
||||
# validates and deletes the edit instead of publishing. Flip it to false only when the dry run
|
||||
# printed what you meant.
|
||||
name: android-promote
|
||||
|
||||
# Two concurrent promotions would race on the same Play edit; the loser fails with a stale-edit
|
||||
# error. One at a time, and never cancel one mid-flight — a half-applied track change is worse
|
||||
# than a queued one.
|
||||
concurrency:
|
||||
group: android-promote
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_code:
|
||||
description: 'versionCode already on Play (e.g. 10816)'
|
||||
required: true
|
||||
to_track:
|
||||
description: 'destination track'
|
||||
required: true
|
||||
default: 'production'
|
||||
from_track:
|
||||
description: 'track to verify it is on, then clear (blank = touch nothing else)'
|
||||
required: false
|
||||
default: 'alpha'
|
||||
notes_tag:
|
||||
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
|
||||
required: false
|
||||
default: ''
|
||||
status:
|
||||
description: 'completed (100%) | inProgress (needs user_fraction) | halted | draft'
|
||||
required: true
|
||||
default: 'completed'
|
||||
user_fraction:
|
||||
description: 'staged rollout fraction for inProgress, e.g. 0.2 (blank otherwise)'
|
||||
required: false
|
||||
default: ''
|
||||
dry_run:
|
||||
description: 'validate only, publish nothing'
|
||||
required: true
|
||||
default: 'true'
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-24.04
|
||||
# Same image as android.yml purely for python3 + openssl (play-upload.py's only deps); it is
|
||||
# already warm on the runner. Nothing here builds.
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Promote
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
TO_TRACK: ${{ inputs.to_track }}
|
||||
FROM_TRACK: ${{ inputs.from_track }}
|
||||
NOTES_TAG: ${{ inputs.notes_tag }}
|
||||
STATUS: ${{ inputs.status }}
|
||||
USER_FRACTION: ${{ inputs.user_fraction }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -- --package io.unom.punktfunk \
|
||||
--promote "$VERSION_CODE" \
|
||||
--track "$TO_TRACK" --status "$STATUS"
|
||||
# Explicit `if`, not `[ ] && …`: under `sh -e` a false AND-OR list that ends up LAST in
|
||||
# the script aborts the step, and these get reordered.
|
||||
if [ -n "$FROM_TRACK" ]; then set -- "$@" --promote-from "$FROM_TRACK"; fi
|
||||
if [ -n "$USER_FRACTION" ]; then set -- "$@" --user-fraction "$USER_FRACTION"; fi
|
||||
if [ -n "$NOTES_TAG" ]; then
|
||||
NOTES="docs/releases/whatsnew/${NOTES_TAG}.txt"
|
||||
# Fail loudly rather than silently publishing with the PREVIOUS release's text still
|
||||
# showing on the store listing.
|
||||
[ -f "$NOTES" ] || { echo "ERROR: no such notes file: $NOTES"; exit 1; }
|
||||
set -- "$@" --release-notes-file "$NOTES"
|
||||
fi
|
||||
if [ "$DRY_RUN" = "true" ]; then set -- "$@" --no-commit; fi
|
||||
echo "promoting versionCode=$VERSION_CODE -> $TO_TRACK (dry_run=$DRY_RUN)"
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
@@ -34,9 +34,10 @@ on:
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
|
||||
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
|
||||
# push is canary (Play `internal`).
|
||||
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
|
||||
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
|
||||
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
|
||||
# reach `alpha` and someone had to promote it by hand in the Console.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -75,6 +76,56 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# FIRST, because it costs a second and everything after it costs ten minutes.
|
||||
#
|
||||
# A release tag MUST carry its own Play "What's new". If the file is absent Play does not
|
||||
# show nothing — it carries the PREVIOUS release's text onto this version, so production
|
||||
# users read notes for a build they are not getting. That is the same defect the v0.22.3
|
||||
# notes shipped (see docs/releases/README.md), and it is invisible until someone reads the
|
||||
# store listing. Failing here also means a missing file cannot leave a half-published
|
||||
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
|
||||
#
|
||||
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
|
||||
# testers costs nothing.
|
||||
- name: Play release notes gate (tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ ! -f "$NOTES" ]; then
|
||||
echo "ERROR: $NOTES does not exist."
|
||||
echo "A production release needs its own Play 'What's new' (<=500 chars, written for"
|
||||
echo "phone/TV users). Without it Play reuses the previous release's text."
|
||||
echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt."
|
||||
exit 1
|
||||
fi
|
||||
# A verbatim copy of another release's file is the same bug wearing a hat: the store
|
||||
# listing still describes the wrong build. Cheap to check, and only ever trips on an
|
||||
# actual copy-paste that was never edited.
|
||||
for other in docs/releases/whatsnew/*.txt; do
|
||||
if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then
|
||||
if cmp -s "$NOTES" "$other"; then
|
||||
echo "ERROR: $NOTES is byte-identical to $other."
|
||||
echo "Write notes describing THIS release, not the one before it."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Length is checked here as well as in play-upload.py. Not redundant: the uploader is
|
||||
# the last line of defence (and the only one android-promote.yml gets), but it runs at
|
||||
# step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build.
|
||||
# Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8,
|
||||
# so `wc -c` would reject a file that is comfortably legal.
|
||||
python3 - "$NOTES" <<'PY'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
text = open(path, encoding="utf-8").read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: {path} is empty.")
|
||||
if len(text) > 500:
|
||||
sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.")
|
||||
print(f"Play release notes OK: {path} ({len(text)}/500 chars)")
|
||||
PY
|
||||
|
||||
# Everything below the checkout used to be four download steps (JDK, SDK,
|
||||
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
|
||||
# into the image now. This guard only re-asserts the Android targets so a
|
||||
@@ -122,11 +173,20 @@ jobs:
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
|
||||
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
|
||||
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
|
||||
esac
|
||||
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
|
||||
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
|
||||
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
|
||||
# On a tag the gate step above already proved this exists, so the else branch is only
|
||||
# ever the canary path. See docs/releases/README.md.
|
||||
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
|
||||
if [ -f "$NOTES" ]; then
|
||||
echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
|
||||
fi
|
||||
echo "android version $VN -> Play track '$TRACK'"
|
||||
|
||||
- name: Build Release (signed AAB + universal APK)
|
||||
@@ -199,15 +259,21 @@ jobs:
|
||||
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
|
||||
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
|
||||
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
|
||||
# promotion to production in the Play console.
|
||||
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
|
||||
#
|
||||
# A tag therefore ships to real users with no further click. Two things keep that honest:
|
||||
# the tag is only pushed once every platform is green, and Play reviews each production
|
||||
# release before it reaches anyone. To ramp instead of going straight to 100%, this is
|
||||
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
|
||||
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
|
||||
- name: Upload to Google Play
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
echo "uploading to Play track '$PLAY_TRACK'"
|
||||
python3 clients/android/ci/play-upload.py \
|
||||
--package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
set -- --package io.unom.punktfunk \
|
||||
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
|
||||
--track "$PLAY_TRACK" --status completed
|
||||
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
|
||||
python3 clients/android/ci/play-upload.py "$@"
|
||||
|
||||
@@ -6,13 +6,23 @@ Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints G
|
||||
error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and
|
||||
tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
|
||||
Usage:
|
||||
Usage (upload a new build):
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
|
||||
--no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes
|
||||
nothing). Use it to dry-run the credentials/AAB without touching the live track.
|
||||
Usage (promote a build that is already on Play, no rebuild):
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--promote 10816 --promote-from alpha --track production
|
||||
|
||||
Promotion assigns an EXISTING versionCode to another track, so what ships to production is the
|
||||
byte-identical artifact the testers ran — rebuilding would burn a fresh versionCode and ship
|
||||
something nobody has tested. --promote-from additionally asserts the code really is on that track
|
||||
(catches a typo'd versionCode before it reaches production) and empties it in the SAME edit, so
|
||||
the move is atomic: testers are never left pinned to a code that production also serves.
|
||||
|
||||
--no-commit: do insert -> upload/assign -> track-update -> validate, then delete the edit
|
||||
(publishes nothing). Use it to dry-run the credentials/AAB/notes without touching the live track.
|
||||
"""
|
||||
import argparse, base64, json, os, subprocess, sys, tempfile, time
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
@@ -104,18 +114,79 @@ def access_token(sa) -> str:
|
||||
return tok["access_token"]
|
||||
|
||||
|
||||
# Play's "What's new" is capped at 500 characters per language. The cap lives in the Console
|
||||
# (the REST reference does not state it) and the API rejects longer text at commit — i.e. AFTER
|
||||
# the AAB has uploaded — so check it up front and print the actual count.
|
||||
NOTES_MAX = 500
|
||||
|
||||
|
||||
def load_release_notes(path, language):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read().strip()
|
||||
if not text:
|
||||
sys.exit(f"ERROR: release-notes file is empty: {path}")
|
||||
if len(text) > NOTES_MAX:
|
||||
sys.exit(f"ERROR: release notes are {len(text)} chars, Play allows {NOTES_MAX}: {path}")
|
||||
print(f"release notes: {len(text)}/{NOTES_MAX} chars ({language})")
|
||||
return [{"language": language, "text": text}]
|
||||
|
||||
|
||||
def put_track(app, edit, tok, track, version_codes, status, user_fraction=None, notes=None):
|
||||
"""PUT one track. An empty version_codes list clears the track (what promotion does to the
|
||||
track it promoted OUT of)."""
|
||||
release = {"status": status, "versionCodes": [str(v) for v in version_codes]}
|
||||
if user_fraction is not None:
|
||||
release["userFraction"] = user_fraction
|
||||
if notes:
|
||||
release["releaseNotes"] = notes
|
||||
# Clearing a track means "no active releases", not "an empty release".
|
||||
body = {"track": track, "releases": [release] if version_codes else []}
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{track}", token=tok,
|
||||
data=json.dumps(body).encode(), content_type="application/json")
|
||||
|
||||
|
||||
def assert_on_track(app, edit, tok, track, vc):
|
||||
"""Fail before anything is written if --promote names a versionCode that is not actually on
|
||||
the track we claim to be promoting out of."""
|
||||
got = call("GET", f"{app}/edits/{edit}/tracks/{track}", token=tok)
|
||||
live = [c for r in got.get("releases", []) for c in r.get("versionCodes", [])]
|
||||
if str(vc) not in live:
|
||||
sys.exit(f"ERROR: versionCode {vc} is not on track '{track}' (it has: {live or 'nothing'})")
|
||||
print(f"verified versionCode={vc} is live on '{track}'")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--package", required=True)
|
||||
ap.add_argument("--aab", required=True)
|
||||
ap.add_argument("--aab", help="upload this bundle (mutually exclusive with --promote)")
|
||||
ap.add_argument("--promote", type=int, metavar="VERSIONCODE",
|
||||
help="assign an already-uploaded versionCode instead of uploading")
|
||||
ap.add_argument("--promote-from", metavar="TRACK",
|
||||
help="with --promote: assert the code is on TRACK, then clear TRACK")
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--user-fraction", type=float,
|
||||
help="staged rollout fraction, 0<f<1; required by --status inProgress")
|
||||
ap.add_argument("--release-notes-file", help="Play 'What's new' text (<=500 chars)")
|
||||
ap.add_argument("--release-notes-language", default="en-US")
|
||||
ap.add_argument("--no-commit", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(a.aab):
|
||||
if bool(a.aab) == bool(a.promote):
|
||||
sys.exit("ERROR: pass exactly one of --aab (upload) or --promote (assign an existing code)")
|
||||
if a.promote_from and not a.promote:
|
||||
sys.exit("ERROR: --promote-from only applies to --promote")
|
||||
# inProgress without a fraction is an API error; halted/completed with one is also rejected.
|
||||
if a.status == "inProgress" and a.user_fraction is None:
|
||||
sys.exit("ERROR: --status inProgress requires --user-fraction")
|
||||
if a.user_fraction is not None and not (0 < a.user_fraction < 1):
|
||||
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
|
||||
if a.aab and not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
|
||||
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
|
||||
if a.release_notes_file else None
|
||||
|
||||
sa = load_sa()
|
||||
tok = access_token(sa)
|
||||
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
|
||||
@@ -123,17 +194,25 @@ def main():
|
||||
|
||||
try:
|
||||
edit = call("POST", f"{app}/edits", token=tok)["id"]
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
|
||||
data=json.dumps({"track": a.track,
|
||||
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
|
||||
content_type="application/json")
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
|
||||
if a.promote:
|
||||
vc = a.promote
|
||||
if a.promote_from:
|
||||
assert_on_track(app, edit, tok, a.promote_from, vc)
|
||||
else:
|
||||
with open(a.aab, "rb") as f:
|
||||
blob = f.read()
|
||||
print(f"uploading {a.aab} ({len(blob)} bytes) ...")
|
||||
vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media",
|
||||
token=tok, data=blob, content_type="application/octet-stream")["versionCode"]
|
||||
print(f"uploaded versionCode={vc}")
|
||||
|
||||
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
|
||||
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
|
||||
# Same edit as the assignment above, so the code is never active on both tracks at once.
|
||||
if a.promote_from:
|
||||
put_track(app, edit, tok, a.promote_from, [], a.status)
|
||||
print(f"cleared track '{a.promote_from}'")
|
||||
|
||||
if a.no_commit:
|
||||
call("POST", f"{app}/edits/{edit}:validate", token=tok)
|
||||
|
||||
@@ -392,7 +392,7 @@ pub(super) fn run_async(
|
||||
// even when the choreographer clock is absent.
|
||||
if let Some(p) = presenter.as_mut() {
|
||||
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
|
||||
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
|
||||
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
|
||||
rendered += 1;
|
||||
}
|
||||
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
|
||||
@@ -822,8 +822,21 @@ fn feed_ready(
|
||||
}
|
||||
}
|
||||
let Some(dst) = codec.input_buffer(idx) else {
|
||||
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
|
||||
continue;
|
||||
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
|
||||
// here leaked one of the codec's input buffers per occurrence — we forget it and the
|
||||
// codec never frees what it never received, so the pipeline quietly runs out of input
|
||||
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
|
||||
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
|
||||
// keyframe request behind it, unlike every sibling path here.
|
||||
//
|
||||
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
|
||||
// advertised is in no state to be fed the rest of the parked queue this pass, and
|
||||
// retrying the same index against every parked AU would burn the whole backlog. The
|
||||
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
|
||||
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
|
||||
free_inputs.push_front(idx);
|
||||
pending_aus.push_front(frame);
|
||||
break;
|
||||
};
|
||||
let au = &frame.data;
|
||||
if au.len() > dst.len() {
|
||||
|
||||
@@ -115,9 +115,14 @@ pub(crate) struct DecodeOptions {
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
|
||||
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
|
||||
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
|
||||
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
|
||||
///
|
||||
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
|
||||
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
||||
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
||||
pub panel_hz: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||||
//! queueing behind the display;
|
||||
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||||
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||||
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||||
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||||
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
|
||||
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
|
||||
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
|
||||
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
|
||||
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
|
||||
//! could be wrong in one direction forever;
|
||||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||||
@@ -20,6 +22,7 @@
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -36,9 +39,9 @@ use super::vsync::VsyncShared;
|
||||
///
|
||||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||||
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||||
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||||
/// signal to widen, not stutter.
|
||||
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
|
||||
/// as a measured latch beyond one panel period (see the adaptation in
|
||||
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
|
||||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||||
|
||||
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
|
||||
@@ -71,6 +74,26 @@ fn latch_margin_ns() -> Option<i64> {
|
||||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||||
|
||||
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
|
||||
/// SurfaceFlinger more work.
|
||||
///
|
||||
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
|
||||
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
|
||||
/// budget before the display has consumed anything, and the presenter then releases faster than
|
||||
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
|
||||
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
|
||||
/// ground truth about what actually reached glass, so it bounds the prediction.
|
||||
///
|
||||
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
|
||||
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
|
||||
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
|
||||
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
|
||||
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
|
||||
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
|
||||
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
|
||||
/// exactly as before — best-effort, never worse than not having it.
|
||||
const UNDISPLAYED_CAP: i32 = 6;
|
||||
|
||||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||||
|
||||
@@ -121,6 +144,14 @@ struct InFlight {
|
||||
/// a HUD-off wireless A/B readable from logcat.
|
||||
pub(super) struct PresentMeter {
|
||||
inner: Mutex<PresentMeterInner>,
|
||||
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
|
||||
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
|
||||
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
|
||||
/// confirm side on the codec's callback thread, once per frame each.
|
||||
undisplayed: AtomicI32,
|
||||
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
|
||||
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
|
||||
confirms: AtomicBool,
|
||||
}
|
||||
|
||||
struct PresentMeterInner {
|
||||
@@ -147,11 +178,23 @@ impl PresentMeter {
|
||||
codec_us: Vec::with_capacity(256),
|
||||
e2e_us: Vec::with_capacity(256),
|
||||
}),
|
||||
undisplayed: AtomicI32::new(0),
|
||||
confirms: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||||
///
|
||||
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
|
||||
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
|
||||
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
|
||||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||||
self.confirms.store(true, Ordering::Relaxed);
|
||||
let _ = self
|
||||
.undisplayed
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
|
||||
Some((v - 1).max(0))
|
||||
});
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -164,6 +207,26 @@ impl PresentMeter {
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
|
||||
fn note_released(&self) {
|
||||
self.undisplayed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Releases still unconfirmed, and whether confirms happen on this device at all.
|
||||
fn outstanding(&self) -> (i32, bool) {
|
||||
(
|
||||
self.undisplayed.load(Ordering::Relaxed),
|
||||
self.confirms.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
|
||||
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
|
||||
/// them. Never stall the stream on a ledger we cannot audit.
|
||||
fn forgive_outstanding(&self) {
|
||||
self.undisplayed.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
|
||||
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
|
||||
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
|
||||
@@ -239,6 +302,13 @@ pub(super) struct Presenter {
|
||||
no_budget: u64,
|
||||
forced: u64,
|
||||
dry: u64,
|
||||
/// Pump passes that held a frame back because too many earlier releases were still
|
||||
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
|
||||
/// the signature of a presenter out-running its display.
|
||||
queue_waits: u64,
|
||||
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
|
||||
/// simply stopped coming. `None` while the rail is down.
|
||||
backed_up_since: Option<i64>,
|
||||
pace_us: Vec<u64>,
|
||||
last_flush: Instant,
|
||||
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
|
||||
@@ -280,6 +350,8 @@ impl Presenter {
|
||||
no_budget: 0,
|
||||
forced: 0,
|
||||
dry: 0,
|
||||
queue_waits: 0,
|
||||
backed_up_since: None,
|
||||
pace_us: Vec::with_capacity(256),
|
||||
last_flush: Instant::now(),
|
||||
margin_ns,
|
||||
@@ -334,6 +406,7 @@ impl Presenter {
|
||||
codec: &MediaCodec,
|
||||
clock: Option<&VsyncShared>,
|
||||
tracker: &DisplayTracker,
|
||||
meter: &PresentMeter,
|
||||
stats: &crate::stats::VideoStats,
|
||||
now_mono_ns: i64,
|
||||
) -> bool {
|
||||
@@ -346,6 +419,10 @@ impl Presenter {
|
||||
self.inflight = None;
|
||||
}
|
||||
}
|
||||
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
|
||||
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
|
||||
// rather than how often a frame happened to be ready.
|
||||
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
|
||||
// Pick the frame this pump may release.
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||||
@@ -373,9 +450,12 @@ impl Presenter {
|
||||
self.frames.pop_front()
|
||||
};
|
||||
let Some(frame) = frame else { return false };
|
||||
if self.inflight.is_some() {
|
||||
if self.inflight.is_some() || backlogged {
|
||||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||||
// vsync tick / loop pass retries the pairing.
|
||||
if backlogged {
|
||||
self.queue_waits += 1;
|
||||
}
|
||||
self.no_budget += 1;
|
||||
match self.fifo_capacity {
|
||||
0 => self.frames.push_back(frame),
|
||||
@@ -412,6 +492,7 @@ impl Presenter {
|
||||
released_at_ns: now_mono_ns,
|
||||
});
|
||||
self.released += 1;
|
||||
meter.note_released();
|
||||
let release_real_ns = now_realtime_ns();
|
||||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||||
if self.pace_us.len() < 4096 {
|
||||
@@ -422,6 +503,33 @@ impl Presenter {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
|
||||
///
|
||||
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
|
||||
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
|
||||
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
|
||||
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
|
||||
/// rather than allowed to wedge the stream.
|
||||
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
|
||||
let (outstanding, confirms_live) = meter.outstanding();
|
||||
if !confirms_live || outstanding < UNDISPLAYED_CAP {
|
||||
self.backed_up_since = None;
|
||||
return false;
|
||||
}
|
||||
match self.backed_up_since {
|
||||
Some(t) if now_ns - t > STALE_REOPEN_NS => {
|
||||
meter.forgive_outstanding();
|
||||
self.backed_up_since = None;
|
||||
self.forced += 1;
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.backed_up_since.get_or_insert(now_ns);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||||
while let Some(f) = self.frames.pop_front() {
|
||||
@@ -434,7 +542,9 @@ impl Presenter {
|
||||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||||
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
|
||||
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
|
||||
/// `pace` (decoded→release) / `latch` (release→displayed) /
|
||||
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
|
||||
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
|
||||
/// A/B headline) / `vsync` (the measured panel period).
|
||||
@@ -462,14 +572,15 @@ impl Presenter {
|
||||
let circ = clock.and_then(|c| {
|
||||
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
|
||||
});
|
||||
let latch_samples = latch.len();
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ms = clock
|
||||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||
.unwrap_or(0.0);
|
||||
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
|
||||
let (outstanding, _) = meter.outstanding();
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
qWait={} unconfirmed={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
|
||||
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
|
||||
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
|
||||
@@ -480,6 +591,8 @@ impl Presenter {
|
||||
self.no_budget,
|
||||
self.forced,
|
||||
self.dry,
|
||||
self.queue_waits,
|
||||
outstanding,
|
||||
pace_p50,
|
||||
pace_max,
|
||||
latch_p50,
|
||||
@@ -493,25 +606,48 @@ impl Presenter {
|
||||
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
|
||||
circ.map(|(_, c)| c).unwrap_or(0),
|
||||
period_ms,
|
||||
panel_ms,
|
||||
panel_ns as f64 / 1e6,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
|
||||
// late and coalesces the next frame into `paced`) mean this device's SF does need
|
||||
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
|
||||
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
|
||||
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
|
||||
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
|
||||
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
|
||||
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
|
||||
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
|
||||
//
|
||||
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
|
||||
// evictions — a second frame decoding while one is held — which happen whenever the
|
||||
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
|
||||
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
|
||||
// of pure display latency the P2e sweep had just measured away.
|
||||
let latch_p50_ns = (latch_p50 * 1e6) as i64;
|
||||
if !self.margin_pinned
|
||||
&& self.margin_ns < LATCH_MARGIN_NS
|
||||
&& panel_ns > 0
|
||||
&& latch_samples >= 8
|
||||
&& latch_p50_ns > panel_ns + self.margin_ns
|
||||
{
|
||||
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
|
||||
log::warn!(
|
||||
"presenter: {} latch misses in 1s — margin widened to {}us",
|
||||
self.paced_drops,
|
||||
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
|
||||
latch_p50,
|
||||
panel_ns as f64 / 1e6,
|
||||
self.margin_ns / 1_000
|
||||
);
|
||||
}
|
||||
if self.queue_waits > 0 {
|
||||
log::warn!(
|
||||
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
|
||||
OnFrameRendered (the display is not keeping up with the release rate)",
|
||||
self.queue_waits,
|
||||
outstanding
|
||||
);
|
||||
}
|
||||
self.paced_drops = 0;
|
||||
self.no_budget = 0;
|
||||
self.forced = 0;
|
||||
self.dry = 0;
|
||||
self.queue_waits = 0;
|
||||
circ
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ pub(super) struct VsyncShared {
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
|
||||
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
|
||||
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
|
||||
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
@@ -231,6 +233,11 @@ struct CallbackCtx {
|
||||
choreographer: *mut c_void,
|
||||
shared: Arc<VsyncShared>,
|
||||
on_tick: Box<dyn Fn() + Send>,
|
||||
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
|
||||
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
|
||||
/// doc) — and its streak state is nobody else's business; only the settled period is
|
||||
/// published, to `shared.panel_period_ns`.
|
||||
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
|
||||
}
|
||||
|
||||
impl CallbackCtx {
|
||||
@@ -240,22 +247,25 @@ impl CallbackCtx {
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||
// valid, widening on a later down-rated window never is.
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
|
||||
// only honest witness to what the panel is doing — the configured mode is not (under a
|
||||
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
|
||||
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
|
||||
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
|
||||
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||
if cur == 0 || spacing < cur - 200_000 {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(spacing, Ordering::Relaxed);
|
||||
}
|
||||
let mut grid = self.panel.get();
|
||||
if grid.observe(spacing) {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(grid.period_ns(), Ordering::Relaxed);
|
||||
log::info!(
|
||||
"vsync: panel grid now {:.2}ms",
|
||||
grid.period_ns() as f64 / 1e6
|
||||
);
|
||||
}
|
||||
self.panel.set(grid);
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
@@ -372,8 +382,9 @@ pub(super) struct VsyncClock {
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) — the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
|
||||
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
@@ -383,11 +394,9 @@ impl VsyncClock {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
panel_period_ns: AtomicI64::new(
|
||||
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
|
||||
),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
@@ -408,6 +417,7 @@ impl VsyncClock {
|
||||
choreographer,
|
||||
shared: thread_shared,
|
||||
on_tick,
|
||||
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
|
||||
};
|
||||
ctx.repost();
|
||||
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
|
||||
|
||||
@@ -61,6 +61,7 @@ default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10
|
||||
policy.
|
||||
|
||||
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`, `PUNKTFUNK_PRESENT_MODE=
|
||||
mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
mailbox|fifo|immediate|fifo_relaxed` (default MAILBOX, FIFO where the surface offers no
|
||||
MAILBOX — AMD on Windows), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
|
||||
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
|
||||
demotion to software on healthy hardware).
|
||||
|
||||
@@ -1670,6 +1670,22 @@ impl IddPushCapturer {
|
||||
// the running correlated/total tally — lives on `StallWatch` (sweep Phase 5.4). It was
|
||||
// ~65 lines of log prose inside `try_consume`, which is the hot loop, and its two
|
||||
// counters were capturer fields that nothing else touched.
|
||||
// One ETW read serves both evidence fields: the prose summary spans the gap plus
|
||||
// the same 300 ms lead-in the report's OS-event correlation uses (the disturbance
|
||||
// that CAUSED the hole lands just before it), while the discriminator counts span
|
||||
// the GAP ONLY — no lead-in: presents from the healthy flow right before the hole
|
||||
// would falsely acquit the content (the stall-ending frame's own present lands at
|
||||
// the window edge and stays well under the acquit bar). Both halves must come from
|
||||
// the same ring snapshot under the same clock anchor, or the prose and the verdict
|
||||
// can disagree about the same hole.
|
||||
let (etw, etw_counts) = self
|
||||
.etw
|
||||
.as_ref()
|
||||
.and_then(|w| {
|
||||
now.checked_sub(stall.gap)
|
||||
.map(|from| w.window_report(from, now, Duration::from_millis(300)))
|
||||
})
|
||||
.unzip();
|
||||
let evidence = StallEvidence {
|
||||
// A publisher re-attach restarts `offered_total` near zero; a ring recreate resets
|
||||
// the stall watch before that can matter, but guard the delta anyway (a restarted
|
||||
@@ -1682,24 +1698,14 @@ impl IddPushCapturer {
|
||||
}
|
||||
}),
|
||||
max_heartbeat_age_ms: self.max_hb_age_us / 1_000,
|
||||
// The probe + ETW reads span the same window the report's OS-event correlation
|
||||
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
// The probe read spans the same window the report's OS-event correlation uses
|
||||
// (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
probes: now
|
||||
.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.zip(self.probes.as_deref())
|
||||
.map(|(from, p)| p.window(from, now)),
|
||||
etw: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.map(|from| w.summary(from, now))
|
||||
}),
|
||||
// The discriminator counts span the GAP ONLY — no lead-in: presents from the
|
||||
// healthy flow right before the hole would falsely acquit the content. The
|
||||
// stall-ending frame's own present lands at the window edge and stays well
|
||||
// under the acquit bar.
|
||||
etw_counts: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap)
|
||||
.map(|from| w.window_counts(from, now))
|
||||
}),
|
||||
etw,
|
||||
etw_counts,
|
||||
};
|
||||
self.stall_watch.report(&stall, now, &evidence);
|
||||
}
|
||||
@@ -2453,6 +2459,18 @@ mod tests {
|
||||
),
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
// A LIVE witness (history true = it demonstrably worked just before the hole) reading
|
||||
// an exact zero is the strongest content conviction — the zero is a measurement, not
|
||||
// an absence.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000))),
|
||||
Some(&counts(0, 0))
|
||||
),
|
||||
StallClass::ContentSilence
|
||||
);
|
||||
// The present witness does NOT overrule the driver's own verdicts or the harder
|
||||
// classes — it only refines compose-silence.
|
||||
assert_eq!(
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! A second provider rides the same session: `Microsoft-Windows-DXGI` (user-mode), filtered to
|
||||
//! `Present`/`PresentMultiplaneOverlay` starts (ids 42/55) — one event per swapchain present,
|
||||
//! stamped with the PRESENTING process id. Together they are the compose-silence discriminator
|
||||
//! ([`EtwWatch::window_counts`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||
//! ([`EtwWatch::window_report`]): DXGI presents flowing while `BltQueueAddEntry` gaps = the OS
|
||||
//! display path dropped composed frames (the real display-path bug); BOTH silent = the content
|
||||
//! stopped presenting (benign pause — menus/loading/game hitch). The predecessor witnesses are
|
||||
//! retired for cause: DxgKrnl id 184 `Present` never fires on the modern redirected path, and
|
||||
@@ -48,7 +48,8 @@ use windows::Win32::System::Diagnostics::Etw::{
|
||||
EVENT_CONTROL_CODE_ENABLE_PROVIDER, EVENT_FILTER_DESCRIPTOR, EVENT_FILTER_TYPE_EVENT_ID,
|
||||
EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, EVENT_TRACE_PROPERTIES,
|
||||
EVENT_TRACE_REAL_TIME_MODE, PROCESSTRACE_HANDLE, PROCESS_TRACE_MODE_EVENT_RECORD,
|
||||
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
|
||||
PROCESS_TRACE_MODE_RAW_TIMESTAMP, PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION,
|
||||
WNODE_FLAG_TRACED_GUID,
|
||||
};
|
||||
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
||||
use windows::Win32::System::Threading::{
|
||||
@@ -122,8 +123,13 @@ fn qpc_freq() -> i64 {
|
||||
})
|
||||
}
|
||||
|
||||
/// The consumer's per-event callback — record id + QPC timestamp (the session's `ClientContext`
|
||||
/// is 1, so `TimeStamp` IS a QPC value) and return; runs on the consumer thread.
|
||||
/// The consumer's per-event callback — record id + timestamp + pid into the ring and return;
|
||||
/// runs on the consumer thread. `TimeStamp` is a raw QPC value only because BOTH halves of the
|
||||
/// clock contract hold: `ClientContext = 1` makes QPC the session clock, and the consumer is
|
||||
/// opened with `PROCESS_TRACE_MODE_RAW_TIMESTAMP`, which is what stops ProcessTrace converting
|
||||
/// every event's timestamp to FILETIME (100 ns units since 1601) on delivery. Without the flag
|
||||
/// the conversion happens REGARDLESS of the session clock, and every `ts <= to_q` comparison
|
||||
/// downstream is against the wrong clock — never true, a witness that silently reads empty.
|
||||
unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
|
||||
if record.is_null() {
|
||||
return;
|
||||
@@ -150,10 +156,10 @@ pub(super) struct EtwWatch {
|
||||
}
|
||||
|
||||
// SAFETY: both fields are plain kernel handle VALUES (u64 wrappers) owned by this watch; every
|
||||
// operation on them (summary reads the static ring; Drop stops/closes) is thread-safe by the ETW
|
||||
// API contract, and the singleton hands out only `Arc<EtwWatch>`.
|
||||
// operation on them (window_report reads the static ring; Drop stops/closes) is thread-safe by
|
||||
// the ETW API contract, and the singleton hands out only `Arc<EtwWatch>`.
|
||||
unsafe impl Send for EtwWatch {}
|
||||
// SAFETY: as above — `&EtwWatch` exposes only `summary` (static-ring reads).
|
||||
// SAFETY: as above — `&EtwWatch` exposes only `window_report` (static-ring reads).
|
||||
unsafe impl Sync for EtwWatch {}
|
||||
|
||||
static WATCH: Mutex<Weak<EtwWatch>> = Mutex::new(Weak::new());
|
||||
@@ -201,8 +207,10 @@ impl EtwWatch {
|
||||
let mut session = CONTROLTRACE_HANDLE::default();
|
||||
// SAFETY: `buf` is a live, zeroed allocation of base + name bytes; every write below is a
|
||||
// field of the properties struct at its head; `LoggerNameOffset = base` points at the
|
||||
// appended name space (ETW copies the name there itself). ClientContext 1 = QPC clock —
|
||||
// what makes event timestamps comparable to our probe windows.
|
||||
// appended name space (ETW copies the name there itself). ClientContext 1 selects QPC as
|
||||
// the SESSION clock — necessary but not sufficient for QPC comparisons: ProcessTrace
|
||||
// still converts every event's timestamp to FILETIME on delivery unless the consumer is
|
||||
// opened with PROCESS_TRACE_MODE_RAW_TIMESTAMP (set below).
|
||||
let rc = unsafe {
|
||||
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = buf.len() as u32;
|
||||
@@ -224,6 +232,11 @@ impl EtwWatch {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// A fresh session gets a fresh ring: the static [`RING`] outlives any `EtwWatch`, so
|
||||
// whatever is in it belongs to a DEAD session — leaking it forward would let a previous
|
||||
// session's presents pose as this session's witness history. Race-free here: the
|
||||
// consumer thread that repopulates it is spawned below.
|
||||
RING.lock().unwrap().clear();
|
||||
|
||||
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
|
||||
// vblank/DPC keywords never reach us. Fatal on failure (the DDI families + queue
|
||||
@@ -240,7 +253,7 @@ impl EtwWatch {
|
||||
return None;
|
||||
}
|
||||
// The DXGI (user-mode) present witness rides the same session. Degraded-not-fatal: a
|
||||
// refusal only costs the per-process present counts — `window_counts` then reports
|
||||
// refusal only costs the per-process present counts — `window_report` then reports
|
||||
// no present history and classification stays honest (Unattributed, never a guess).
|
||||
if !enable_provider(session, &DXGI, &DXGI_FILTER_IDS) {
|
||||
tracing::debug!(
|
||||
@@ -252,8 +265,12 @@ impl EtwWatch {
|
||||
LoggerName: PWSTR(name.as_ptr() as *mut _),
|
||||
..Default::default()
|
||||
};
|
||||
log.Anonymous1.ProcessTraceMode =
|
||||
PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
|
||||
// RAW_TIMESTAMP is load-bearing: it stops ProcessTrace converting `EVENT_HEADER.TimeStamp`
|
||||
// to FILETIME on delivery, so events arrive stamped in the session clock (QPC, per the
|
||||
// ClientContext above) — the only clock the window edges are computed in.
|
||||
log.Anonymous1.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME
|
||||
| PROCESS_TRACE_MODE_EVENT_RECORD
|
||||
| PROCESS_TRACE_MODE_RAW_TIMESTAMP;
|
||||
log.Anonymous2.EventRecordCallback = Some(on_event);
|
||||
// SAFETY: `log` is a fully-initialized local; `name` outlives the call (OpenTrace copies
|
||||
// what it needs before returning).
|
||||
@@ -303,14 +320,34 @@ impl EtwWatch {
|
||||
Some(Self { session, consumer })
|
||||
}
|
||||
|
||||
/// Summarize the DDI activity inside `[from, to]` — the correlation line a stall report
|
||||
/// carries. Brackets that merely SPAN the window count too (a freeze-long `SetPowerState`
|
||||
/// has both edges outside the hole it caused). `"none"` when the window is clean.
|
||||
pub(super) fn summary(&self, from: Instant, to: Instant) -> String {
|
||||
// Instant → QPC: anchor both clocks now and offset backwards.
|
||||
/// One stall window's ETW evidence, both halves from a SINGLE ring snapshot under a SINGLE
|
||||
/// `(Instant::now(), qpc_now())` anchor: the DDI/present prose summary a stall report
|
||||
/// carries, and the structured discriminator counts the classifier folds in. The summary
|
||||
/// covers `[hole_from - lead_in, hole_to]` — the disturbance that CAUSED a hole lands just
|
||||
/// before DWM stops delivering, so the prose needs the lead-in. The counts cover
|
||||
/// `[hole_from, hole_to]` ONLY — presents from the healthy flow inside the lead-in would
|
||||
/// falsely acquit the content. Two separate reads (two locks, two anchors, syscalls in
|
||||
/// between) would let events arriving between them make the prose and the verdict disagree
|
||||
/// about the same hole — hence one method returning both.
|
||||
///
|
||||
/// Brackets that merely SPAN the summary window count too (a freeze-long `SetPowerState`
|
||||
/// has both edges outside the hole it caused). The summary reads `"none"` when the window
|
||||
/// is clean.
|
||||
pub(super) fn window_report(
|
||||
&self,
|
||||
hole_from: Instant,
|
||||
hole_to: Instant,
|
||||
lead_in: Duration,
|
||||
) -> (String, EtwWindowCounts) {
|
||||
// Instant → QPC: anchor both clocks once and offset backwards; every window edge below
|
||||
// derives from this one anchor.
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(hole_to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(hole_from), freq);
|
||||
let summary_from_q = from_q - duration_qpc(lead_in, freq);
|
||||
// One snapshot, then the lock drops: everything below — including the OpenProcess
|
||||
// syscalls behind `process_name` — runs off the copy, so the consumer callback never
|
||||
// queues behind a stall report.
|
||||
let events: Vec<(i64, u16, u32)> = {
|
||||
let ring = RING.lock().unwrap();
|
||||
ring.iter()
|
||||
@@ -318,6 +355,7 @@ impl EtwWatch {
|
||||
.copied()
|
||||
.collect()
|
||||
};
|
||||
let counts = count_window(&events, from_q, to_q, duration_qpc(LOOKBACK, freq));
|
||||
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
|
||||
let mut parts = Vec::new();
|
||||
for (start_id, stop_id, label) in [
|
||||
@@ -335,7 +373,7 @@ impl EtwWatch {
|
||||
} else if id == stop_id {
|
||||
if let Some(s) = open.take() {
|
||||
// The bracket [s, ts] counts when it intersects the window.
|
||||
if s <= to_q && ts >= from_q {
|
||||
if s <= to_q && ts >= summary_from_q {
|
||||
count += 1;
|
||||
max_ms = max_ms.max(ms(ts - s));
|
||||
}
|
||||
@@ -362,43 +400,34 @@ impl EtwWatch {
|
||||
] {
|
||||
let count = events
|
||||
.iter()
|
||||
.filter(|(ts, i, _)| *i == id && *ts >= from_q && *ts <= to_q)
|
||||
.filter(|(ts, i, _)| *i == id && *ts >= summary_from_q && *ts <= to_q)
|
||||
.count();
|
||||
if count > 0 {
|
||||
parts.push(format!("{label}×{count}"));
|
||||
}
|
||||
}
|
||||
// Present + queue accounting (DXGI 42/55 + BltQueueAddEntry/Complete): total presents
|
||||
// inside the window plus the top presenters, NAMED — the line that splits a
|
||||
// inside the summary window plus the top presenters, NAMED — the line that splits a
|
||||
// compose-silence hole into "the content stopped presenting" (no presents anywhere)
|
||||
// versus "presents flowed and the display path dropped them" (presents at rate while
|
||||
// the queue starves). "Present×0" is printed explicitly when the stream has history
|
||||
// but the window is empty — silence is a finding, not an absence.
|
||||
// the queue starves). "Present×0" is printed explicitly when the witness was LIVE
|
||||
// before the hole ([`LOOKBACK`]) but the window is empty — silence is a finding, not
|
||||
// an absence; a dead witness's window prints nothing rather than a fake zero.
|
||||
let mut per_pid: Vec<(u32, u32)> = Vec::new();
|
||||
let mut have_present_history = false;
|
||||
let (mut adds, mut completes) = (0u32, 0u32);
|
||||
let mut have_queue_history = false;
|
||||
for &(ts, id, pid) in &events {
|
||||
if ts < summary_from_q || ts > to_q {
|
||||
continue;
|
||||
}
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
have_present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||
Some((_, c)) => *c += 1,
|
||||
None => per_pid.push((pid, 1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID | BLT_COMPLETE_ID => {
|
||||
have_queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
if id == BLT_ADD_ID {
|
||||
adds += 1;
|
||||
} else {
|
||||
completes += 1;
|
||||
}
|
||||
match per_pid.iter_mut().find(|(p, _)| *p == pid) {
|
||||
Some((_, c)) => *c += 1,
|
||||
None => per_pid.push((pid, 1)),
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => adds += 1,
|
||||
BLT_COMPLETE_ID => completes += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -415,61 +444,80 @@ impl EtwWatch {
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
parts.push(format!("Present×{total}({top})"));
|
||||
} else if have_present_history {
|
||||
} else if counts.present_history {
|
||||
parts.push("Present×0".to_string());
|
||||
}
|
||||
if have_queue_history {
|
||||
if counts.queue_history || adds > 0 || completes > 0 {
|
||||
parts.push(format!("blt-queue add×{adds} complete×{completes}"));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
let summary = if parts.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// The structured discriminator read for `[from, to]` (the stall classifier's evidence):
|
||||
/// how many swapchain presents (DXGI 42/55, any process) and how many virtual-display
|
||||
/// queue entries (`BltQueueAddEntry`) landed in the window, plus whether each stream has
|
||||
/// EVER produced an event (distinguishing a true zero from a witness that is not working —
|
||||
/// e.g. the DXGI enable was refused, or an OS build renumbered the BltQueue events).
|
||||
pub(super) fn window_counts(&self, from: Instant, to: Instant) -> EtwWindowCounts {
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let ring = RING.lock().unwrap();
|
||||
let mut out = EtwWindowCounts::default();
|
||||
for &(ts, id, _) in ring.iter() {
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
out.present_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.presents += 1;
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => {
|
||||
out.queue_history = true;
|
||||
if ts >= from_q && ts <= to_q {
|
||||
out.queue_adds += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
(summary, counts)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`EtwWatch::window_counts`]'s read: the compose-silence discriminator's structured evidence.
|
||||
/// Witness-liveness lookback: the [`EtwWindowCounts`] history flags are true only when the
|
||||
/// stream produced at least one event inside the `LOOKBACK` window ENDING at the hole's start.
|
||||
/// "Ever produced an event" would be wrong in both directions: an event that arrived only AFTER
|
||||
/// the hole (the resume burst, the stall-ending frame) proves nothing about whether the witness
|
||||
/// was working DURING it, and a provider that died mid-session (or whose events aged out of the
|
||||
/// ring) would keep flying a stale known-working flag forever. Demonstrated life immediately
|
||||
/// BEFORE the hole is the claim the classifier actually needs; 5 s is far longer than any
|
||||
/// pre-stall active-flow gate, so a genuinely working witness cannot blink false across a
|
||||
/// frame-time lull.
|
||||
const LOOKBACK: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The discriminator's windowing math, factored pure (plain i64 QPC-tick arithmetic, no ETW,
|
||||
/// no clock reads) so the ring→counts contract is unit-testable without a session: presents
|
||||
/// (DXGI 42/55, any process) and queue entries (`BltQueueAddEntry`) inside `[from_q, to_q]`,
|
||||
/// witness liveness from `[from_q - lookback_q, from_q]` (see [`LOOKBACK`]). A
|
||||
/// `BltQueueCompleteIndirectPresent` proves the queue witness works exactly as an add does —
|
||||
/// both ride the same provider enable — so either satisfies `queue_history`.
|
||||
fn count_window(
|
||||
events: &[(i64, u16, u32)],
|
||||
from_q: i64,
|
||||
to_q: i64,
|
||||
lookback_q: i64,
|
||||
) -> EtwWindowCounts {
|
||||
let mut out = EtwWindowCounts::default();
|
||||
for &(ts, id, _) in events {
|
||||
let in_window = ts >= from_q && ts <= to_q;
|
||||
let in_lookback = ts >= from_q.saturating_sub(lookback_q) && ts <= from_q;
|
||||
match id {
|
||||
DXGI_PRESENT_ID | DXGI_PRESENT_MPO_ID => {
|
||||
out.present_history |= in_lookback;
|
||||
if in_window {
|
||||
out.presents += 1;
|
||||
}
|
||||
}
|
||||
BLT_ADD_ID => {
|
||||
out.queue_history |= in_lookback;
|
||||
if in_window {
|
||||
out.queue_adds += 1;
|
||||
}
|
||||
}
|
||||
BLT_COMPLETE_ID => out.queue_history |= in_lookback,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`EtwWatch::window_report`]'s structured half: the compose-silence discriminator's evidence.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct EtwWindowCounts {
|
||||
/// Swapchain presents (any process — the game AND dwm both count) inside the window.
|
||||
pub(super) presents: u32,
|
||||
/// `BltQueueAddEntry` events (frames entering the virtual display's kernel queue) inside it.
|
||||
pub(super) queue_adds: u32,
|
||||
/// The present stream has produced at least one event EVER (witness known-working).
|
||||
/// The present stream demonstrated liveness inside [`LOOKBACK`] BEFORE the hole opened — a
|
||||
/// working witness whose in-window zero is a reading, not a dead one whose zero is noise.
|
||||
pub(super) present_history: bool,
|
||||
/// The queue stream has produced at least one event EVER (witness known-working).
|
||||
/// Queue-stream liveness inside [`LOOKBACK`] before the hole (`BltQueueAddEntry` or
|
||||
/// `BltQueueCompleteIndirectPresent` — either proves the witness works).
|
||||
pub(super) queue_history: bool,
|
||||
}
|
||||
|
||||
@@ -561,3 +609,72 @@ impl Drop for EtwWatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The module only compiles on Windows (lib.rs gates `mod windows`), so plain `cfg(test)` here
|
||||
// already means "Windows tests" — and [`count_window`] itself is pure tick math, no session.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// [`count_window`]'s contract: counts come from the hole window `[from, to]`; liveness
|
||||
/// comes ONLY from the lookback window ending at the hole's start. An event after the hole
|
||||
/// (the resume burst) or older than the lookback (a dead provider's leftovers) must not fly
|
||||
/// the known-working flag — those are exactly the shapes that used to convict every
|
||||
/// compose-silence hole as content.
|
||||
#[test]
|
||||
fn count_window_liveness_and_windowing() {
|
||||
// Hole [1000, 2000], lookback 500 → liveness window [500, 1000]. Plain ticks.
|
||||
let (from, to, lb) = (1_000i64, 2_000i64, 500i64);
|
||||
let ev = |ts: i64, id: u16| (ts, id, 42u32);
|
||||
|
||||
// The healthy shape: liveness demonstrated before the hole, activity inside it.
|
||||
let events = [
|
||||
ev(600, DXGI_PRESENT_ID), // lookback → present witness live
|
||||
ev(700, BLT_COMPLETE_ID), // lookback → queue witness live (completes count)
|
||||
ev(1_100, DXGI_PRESENT_ID), // in-window present
|
||||
ev(1_200, DXGI_PRESENT_MPO_ID), // in-window present (MPO path)
|
||||
ev(1_300, BLT_ADD_ID), // in-window queue add
|
||||
ev(1_400, 430), // non-witness id: never counted here
|
||||
];
|
||||
assert_eq!(
|
||||
count_window(&events, from, to, lb),
|
||||
EtwWindowCounts {
|
||||
presents: 2,
|
||||
queue_adds: 1,
|
||||
present_history: true,
|
||||
queue_history: true,
|
||||
}
|
||||
);
|
||||
|
||||
// In-window events count but do NOT confer liveness — the witness must have worked
|
||||
// BEFORE the hole for its zeros elsewhere to mean anything.
|
||||
let window_only = [ev(1_500, DXGI_PRESENT_ID), ev(1_600, BLT_ADD_ID)];
|
||||
let c = count_window(&window_only, from, to, lb);
|
||||
assert_eq!((c.presents, c.queue_adds), (1, 1));
|
||||
assert!(!c.present_history && !c.queue_history);
|
||||
|
||||
// An event only AFTER the hole proves nothing about the witness during it.
|
||||
let after_only = [ev(2_100, DXGI_PRESENT_ID), ev(2_200, BLT_ADD_ID)];
|
||||
assert_eq!(
|
||||
count_window(&after_only, from, to, lb),
|
||||
EtwWindowCounts::default()
|
||||
);
|
||||
|
||||
// Events that aged past the lookback (a previous session's leftovers) don't either.
|
||||
let stale = [ev(499, DXGI_PRESENT_ID), ev(1, BLT_ADD_ID)];
|
||||
assert_eq!(
|
||||
count_window(&stale, from, to, lb),
|
||||
EtwWindowCounts::default()
|
||||
);
|
||||
|
||||
// Both lookback edges are inclusive; the hole-start event is both liveness and count.
|
||||
let edges = [ev(500, DXGI_PRESENT_ID), ev(1_000, BLT_ADD_ID)];
|
||||
let c = count_window(&edges, from, to, lb);
|
||||
assert!(c.present_history && c.queue_history);
|
||||
assert_eq!((c.presents, c.queue_adds), (0, 1));
|
||||
|
||||
// A lookback reaching below tick 0 saturates instead of wrapping.
|
||||
let c = count_window(&[ev(0, DXGI_PRESENT_ID)], 3, to, i64::MAX);
|
||||
assert!(c.present_history);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(super) struct StallEvidence {
|
||||
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
|
||||
/// session is unavailable (non-admin dev run).
|
||||
pub(super) etw: Option<String>,
|
||||
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_counts`]) —
|
||||
/// The structured present-vs-queue counts for the window ([`EtwWatch::window_report`]) —
|
||||
/// the compose-silence discriminator: presents flowing while the queue starves = the OS
|
||||
/// display path dropped composed frames; both silent = the content stopped presenting.
|
||||
/// `None` when the ETW session is unavailable.
|
||||
|
||||
@@ -424,11 +424,31 @@ fn pump(
|
||||
// Build the decoder for the codec the host resolved (never assume HEVC), honoring the
|
||||
// Settings backend preference (auto/vaapi/software).
|
||||
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
// The WIRE codec is the negotiated truth; the FFmpeg id is meaningful only where
|
||||
// FFmpeg decodes it. `ffmpeg_codec_id`'s fallthrough maps every unknown wire bit —
|
||||
// PyroWave included — to HEVC, so logging it unconditionally claimed
|
||||
// `codec_id=HEVC` for wavelet sessions that never touch FFmpeg at all.
|
||||
let codec = match connector.codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "H264",
|
||||
punktfunk_core::quic::CODEC_HEVC => "HEVC",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||||
_ => "unknown",
|
||||
};
|
||||
if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
tracing::info!(
|
||||
codec,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
codec,
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
}
|
||||
// A negotiated PyroWave session decodes on the presenter's device, no FFmpeg —
|
||||
// reachable only through the explicit preference above (resolve_codec never
|
||||
// auto-picks the bit), so failing loudly here is failing an opted-in experiment.
|
||||
|
||||
@@ -321,6 +321,88 @@ pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a decoder for `codec_id` that can actually drive `hw_pix_fmt` through
|
||||
/// `hw_device_ctx` — the open-time capability check every hardware backend needs.
|
||||
///
|
||||
/// `avcodec_find_decoder(id)` is NOT that: it returns the registry's FIRST decoder for
|
||||
/// the id, and upstream orders the native `av1` decoder LAST on purpose ("hwaccel hooks
|
||||
/// only, so prefer external decoders" — allcodecs.c), behind libdav1d/libaom. The ID
|
||||
/// lookup therefore hands every AV1 session a pure software decoder that silently
|
||||
/// ignores `hw_device_ctx` and never calls `get_format`; each frame then fails the
|
||||
/// backend's hw-format guard and the session burns the demotion ladder MID-STREAM
|
||||
/// (~1 s per rung — field-logged as 68 Vulkan fails → D3D11VA → 102 fails → software,
|
||||
/// ~3 s of black) instead of failing here at open in milliseconds. H.264/HEVC never hit
|
||||
/// this only because their native decoders happen to be registered first.
|
||||
///
|
||||
/// The walk mirrors what `avcodec_find_decoder` would do, restricted to decoders whose
|
||||
/// `avcodec_get_hw_config` advertises the wanted surface via
|
||||
/// `AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX` — registry order still wins among those,
|
||||
/// so H.264/HEVC keep selecting exactly the decoder they always did. The error names
|
||||
/// the decoders that WERE found, so a log reader can tell "this build has no AV1
|
||||
/// hwaccel at all" from "no AV1 decoder exists, period".
|
||||
pub(crate) fn find_hw_decoder(
|
||||
codec_id: ffmpeg::codec::Id,
|
||||
hw_pix_fmt: ffmpeg::ffi::AVPixelFormat,
|
||||
) -> Result<*const ffmpeg::ffi::AVCodec> {
|
||||
use ffmpeg::ffi;
|
||||
let want: ffi::AVCodecID = codec_id.into();
|
||||
let mut found: Vec<String> = Vec::new();
|
||||
// SAFETY: `av_codec_iterate` walks libav's static codec registry (`opaque` is its
|
||||
// cursor) and returns static `AVCodec`s; `avcodec_get_hw_config` only reads the
|
||||
// codec's own static hw-config table, NULL-terminated by returning null past the end.
|
||||
unsafe {
|
||||
let mut opaque = std::ptr::null_mut();
|
||||
loop {
|
||||
let codec = ffi::av_codec_iterate(&mut opaque);
|
||||
if codec.is_null() {
|
||||
break;
|
||||
}
|
||||
if (*codec).id != want || ffi::av_codec_is_decoder(codec) == 0 {
|
||||
continue;
|
||||
}
|
||||
for i in 0.. {
|
||||
let cfg = ffi::avcodec_get_hw_config(codec, i);
|
||||
if cfg.is_null() {
|
||||
break;
|
||||
}
|
||||
if (*cfg).methods & ffi::AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX as i32 != 0
|
||||
&& (*cfg).pix_fmt == hw_pix_fmt
|
||||
{
|
||||
return Ok(codec);
|
||||
}
|
||||
}
|
||||
found.push(
|
||||
std::ffi::CStr::from_ptr((*codec).name)
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
bail!("no {codec_id:?} decoder in this FFmpeg build");
|
||||
}
|
||||
bail!(
|
||||
"no {codec_id:?} decoder in this FFmpeg build can drive {hw_pix_fmt:?} via \
|
||||
hw_device_ctx (found: {})",
|
||||
found.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
/// The name of a registry `AVCodec` (`(*codec).name`), owned — the field every decode
|
||||
/// log carries so `decoder="av1"` vs `decoder="libdav1d"` is one glance, not a debugger.
|
||||
///
|
||||
/// # Safety
|
||||
/// `codec` must point to a registered `AVCodec` (their `name` is a static NUL-terminated
|
||||
/// string, valid for the process).
|
||||
pub(crate) unsafe fn codec_name(codec: *const ffmpeg::ffi::AVCodec) -> String {
|
||||
// SAFETY: caller guarantees a registered AVCodec; `name` is its static C string.
|
||||
unsafe {
|
||||
std::ffi::CStr::from_ptr((*codec).name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
|
||||
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
|
||||
pub fn decodable_codecs() -> u8 {
|
||||
@@ -435,7 +517,11 @@ impl Decoder {
|
||||
vaapi_tried = true;
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = v.name(),
|
||||
"VAAPI hardware decode active (zero-copy dmabuf)"
|
||||
);
|
||||
return done(Backend::Vaapi(v));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -470,6 +556,7 @@ impl Decoder {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = d.name(),
|
||||
"D3D11VA hardware decode active (shared-texture hand-off)"
|
||||
);
|
||||
return done(Backend::D3d11va(d));
|
||||
@@ -490,6 +577,7 @@ impl Decoder {
|
||||
Ok(v) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = v.name(),
|
||||
"Vulkan Video hardware decode active (presenter-shared device)"
|
||||
);
|
||||
return done(Backend::Vulkan(v));
|
||||
@@ -520,7 +608,11 @@ impl Decoder {
|
||||
if choice != "software" && choice != "vulkan" && !vaapi_tried {
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = v.name(),
|
||||
"VAAPI hardware decode active (zero-copy dmabuf)"
|
||||
);
|
||||
return done(Backend::Vaapi(v));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -548,6 +640,7 @@ impl Decoder {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
decoder = d.name(),
|
||||
"D3D11VA hardware decode active (shared-texture hand-off)"
|
||||
);
|
||||
return done(Backend::D3d11va(d));
|
||||
@@ -724,6 +817,7 @@ impl Decoder {
|
||||
match VaapiDecoder::new(self.codec_id) {
|
||||
Ok(v) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
decoder = v.name(),
|
||||
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
|
||||
self.backend = Backend::Vaapi(v);
|
||||
self.vaapi_fails = 0;
|
||||
@@ -745,6 +839,7 @@ impl Decoder {
|
||||
) {
|
||||
Ok(d) => {
|
||||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||||
decoder = d.name(),
|
||||
"Vulkan Video decode failing repeatedly — demoting to D3D11VA");
|
||||
self.backend = Backend::D3d11va(d);
|
||||
self.vaapi_fails = 0;
|
||||
|
||||
@@ -552,6 +552,10 @@ pub(crate) struct D3d11vaDecoder {
|
||||
/// ([`crate::video::VulkanDecodeDevice::d3d11_hdr10`]) — PQ streams get the HDR
|
||||
/// pass-through ring; without it they keep the tonemap-to-sRGB ring.
|
||||
hdr10_out: bool,
|
||||
/// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"`
|
||||
/// is the difference between hardware decode and a silent CPU fallback, so every
|
||||
/// log a field report leans on carries it.
|
||||
name: String,
|
||||
}
|
||||
|
||||
// SAFETY: the libav pointers are this decoder's own allocations (freed once in `Drop`) and the COM
|
||||
@@ -609,10 +613,16 @@ impl D3d11vaDecoder {
|
||||
if !d3d11va_decode_supported(hw_device.as_ptr()) {
|
||||
bail!("GPU can't create the D3D11VA decode surface pool");
|
||||
}
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
// NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST
|
||||
// decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only
|
||||
// native decoder last) — a software decoder that silently ignores
|
||||
// `hw_device_ctx` and fails every frame's D3D11-format guard mid-stream,
|
||||
// even when the DXVA profile + pool probes above all passed. Select by
|
||||
// capability instead: the first decoder that can drive AV_PIX_FMT_D3D11
|
||||
// via hw_device_ctx, or fail here at open.
|
||||
let codec =
|
||||
crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_D3D11)?;
|
||||
let name = crate::video::codec_name(codec);
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(get_format_d3d11);
|
||||
@@ -638,10 +648,16 @@ impl D3d11vaDecoder {
|
||||
video_context1,
|
||||
ring: None,
|
||||
hdr10_out,
|
||||
name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected decoder's registry name (e.g. `"av1"`) — see the field doc.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<D3d11Frame>> {
|
||||
use ffmpeg::ffi;
|
||||
// SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole
|
||||
@@ -830,6 +846,7 @@ impl D3d11vaDecoder {
|
||||
src_desc.Height,
|
||||
index,
|
||||
color.is_pq(),
|
||||
&self.name,
|
||||
);
|
||||
Ok(D3d11Frame {
|
||||
width,
|
||||
@@ -883,7 +900,15 @@ impl Drop for D3d11vaDecoder {
|
||||
/// One-time dump of the first decoded surface's layout — the forensics for a new GPU/driver.
|
||||
/// `tex_*` is the DXVA-aligned decode surface (>= the frame); the gap is the padding the
|
||||
/// stream source rect excludes.
|
||||
fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32, pq: bool) {
|
||||
fn log_layout_once(
|
||||
width: u32,
|
||||
height: u32,
|
||||
tex_w: u32,
|
||||
tex_h: u32,
|
||||
index: u32,
|
||||
pq: bool,
|
||||
decoder: &str,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if ONCE.swap(false, Ordering::Relaxed) {
|
||||
@@ -894,6 +919,7 @@ fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32,
|
||||
tex_h,
|
||||
slice = index,
|
||||
pq,
|
||||
decoder,
|
||||
"D3D11VA first frame"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ impl SoftwareDecoder {
|
||||
(*raw).thread_count = 0; // auto
|
||||
}
|
||||
let decoder = ctx.decoder().video().context("open video decoder")?;
|
||||
// Every construction site (session open, preference, mid-stream demotion) says
|
||||
// which decoder actually opened: for AV1 the ID lookup means libdav1d here —
|
||||
// deliberately (fastest CPU path; the native `av1` decoder has no software
|
||||
// path at all) — and the name in the log is what keeps that distinguishable
|
||||
// from the hardware lanes' capability-selected decoders.
|
||||
tracing::info!(?codec_id, decoder = codec.name(), "software decoder opened");
|
||||
Ok(SoftwareDecoder { decoder, sws: None })
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ pub(crate) struct VaapiDecoder {
|
||||
hw_device: AvBuffer,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
/// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"`
|
||||
/// is the difference between hardware decode and a silent CPU fallback, so every
|
||||
/// log a field report leans on carries it.
|
||||
name: String,
|
||||
}
|
||||
|
||||
// SAFETY: the three raw pointers (`ctx`, `packet`, `frame`) are allocations this decoder makes in
|
||||
@@ -80,11 +84,15 @@ impl VaapiDecoder {
|
||||
// Owned from here: every `bail!` below drops it, so none of them unref by hand.
|
||||
let hw_device = AvBuffer::from_raw(hw_device)
|
||||
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
|
||||
// The negotiated codec's decoder id (av_codec_id maps 1:1 from ffmpeg::codec::Id).
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
// NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST
|
||||
// decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only
|
||||
// native decoder last) — a software decoder that silently ignores
|
||||
// `hw_device_ctx` and fails every frame's VAAPI-format guard mid-stream.
|
||||
// Select by capability instead: the first decoder that can drive
|
||||
// AV_PIX_FMT_VAAPI via hw_device_ctx, or fail here at open.
|
||||
let codec =
|
||||
crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_VAAPI)?;
|
||||
let name = crate::video::codec_name(codec);
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vaapi);
|
||||
@@ -109,10 +117,16 @@ impl VaapiDecoder {
|
||||
hw_device,
|
||||
packet: ffi::av_packet_alloc(),
|
||||
frame: ffi::av_frame_alloc(),
|
||||
name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected decoder's registry name (e.g. `"av1"`) — see the field doc.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<DmabufFrame>> {
|
||||
use ffmpeg::ffi;
|
||||
// SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole
|
||||
@@ -207,7 +221,7 @@ impl VaapiDecoder {
|
||||
// a single modifier for the texture.
|
||||
let modifier = d.objects[0].format_modifier;
|
||||
|
||||
log_descriptor_once(d, sw_format, fourcc, modifier);
|
||||
log_descriptor_once(d, sw_format, fourcc, modifier, &self.name);
|
||||
|
||||
Ok(DmabufFrame {
|
||||
width: (*self.frame).width as u32,
|
||||
@@ -233,6 +247,7 @@ fn log_descriptor_once(
|
||||
sw: ffmpeg_next::ffi::AVPixelFormat,
|
||||
fourcc: u32,
|
||||
modifier: u64,
|
||||
decoder: &str,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
@@ -250,6 +265,7 @@ fn log_descriptor_once(
|
||||
nb_layers = d.nb_layers,
|
||||
?layers,
|
||||
modifier = format_args!("{:#018x}", modifier),
|
||||
decoder,
|
||||
"VAAPI dmabuf descriptor layout (first frame)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ pub(crate) struct VulkanDecoder {
|
||||
/// (resolved through the same get_proc_addr chain FFmpeg uses).
|
||||
wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores,
|
||||
vk_device: pf_ffvk::VkDevice,
|
||||
/// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"`
|
||||
/// is the difference between hardware decode and a silent CPU fallback, so every
|
||||
/// log a field report leans on carries it.
|
||||
name: String,
|
||||
/// Storage `AVVulkanDeviceContext` points into (extension string arrays + the
|
||||
/// feature chain) — FFmpeg reads the extension lists past init (frames-context
|
||||
/// setup keys code paths off them), so this lives exactly as long as `hw_device`.
|
||||
@@ -245,10 +249,15 @@ impl VulkanDecoder {
|
||||
}
|
||||
let vk_device = (*hwctx).act_dev;
|
||||
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
// NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST
|
||||
// decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only
|
||||
// native decoder last) — a software decoder that silently ignores
|
||||
// `hw_device_ctx` and fails every frame's Vulkan-format guard mid-stream.
|
||||
// Select by capability instead: the first decoder that can drive
|
||||
// AV_PIX_FMT_VULKAN via hw_device_ctx, or fail here at open.
|
||||
let codec =
|
||||
crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_VULKAN)?;
|
||||
let name = crate::video::codec_name(codec);
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vulkan);
|
||||
@@ -270,11 +279,17 @@ impl VulkanDecoder {
|
||||
frame: ffi::av_frame_alloc(),
|
||||
wait_semaphores,
|
||||
vk_device,
|
||||
name,
|
||||
_ctx_storage: store,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected decoder's registry name (e.g. `"av1"`) — see the field doc.
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub(crate) fn decode(&mut self, au: &[u8]) -> Result<Option<VkVideoFrame>> {
|
||||
use ffmpeg::ffi;
|
||||
// SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole
|
||||
@@ -388,6 +403,7 @@ impl VulkanDecoder {
|
||||
(*fc).width,
|
||||
(*fc).height,
|
||||
sw,
|
||||
&self.name,
|
||||
);
|
||||
Ok(VkVideoFrame {
|
||||
vkframe: vkf as usize,
|
||||
@@ -423,6 +439,7 @@ fn log_layout_once(
|
||||
pool_w: i32,
|
||||
pool_h: i32,
|
||||
sw: ffmpeg::ffi::AVPixelFormat,
|
||||
decoder: &str,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
@@ -433,6 +450,7 @@ fn log_layout_once(
|
||||
pool_w,
|
||||
pool_h,
|
||||
?sw,
|
||||
decoder,
|
||||
"Vulkan Video first frame"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,7 +365,8 @@ fn detail(id: RowId) -> &'static str {
|
||||
}
|
||||
RowId::Chroma444 => {
|
||||
"Full-colour video: crisp small text and thin lines, at more bandwidth. \
|
||||
HEVC only, and only where the host can encode it."
|
||||
Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders \
|
||||
stream 4:2:0 and the session falls back silently."
|
||||
}
|
||||
RowId::Audio => "The speaker layout requested from the host.",
|
||||
RowId::Mic => {
|
||||
|
||||
@@ -35,10 +35,14 @@ pub const DS_FEATURE_PAIRING: &[u8] = &[ // report 0x09 (pairing info: MAC at by
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date)
|
||||
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date); bytes 44..46
|
||||
// = update version, kept ABOVE Sony's real releases (0x0630 as of 2026-08) — an older value
|
||||
// makes PlayStation Accessories and libScePad titles demand a firmware update the virtual pad
|
||||
// cannot take ("can't complete the update"), and ≥ 0x0224 is what puts writers on the
|
||||
// COMPATIBLE_VIBRATION2 convention parse_ds_output accepts alongside flag0.
|
||||
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
|
||||
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, 0x09, 0x00, 0x00,
|
||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
@@ -494,10 +498,13 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises 0x0154 so the
|
||||
// kernel and SDL stay on the flag0 convention, but a writer that hardcodes v2 would
|
||||
// otherwise have its rumble — including stops — silently ignored, and a missed stop
|
||||
// buzzes for the rest of the session (the 500 ms refresh re-sends stale state forever).
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version
|
||||
// above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater
|
||||
// quiet), so the kernel and SDL write the v2 flag — while older writers, and any
|
||||
// that never read the version, stay on flag0. Both conventions must land here: a
|
||||
// rumble dropped on either — including stops — is silently ignored, and a missed
|
||||
// stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state
|
||||
// forever).
|
||||
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
|
||||
@@ -204,6 +204,11 @@ struct StreamState {
|
||||
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
|
||||
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
|
||||
hdr: bool,
|
||||
/// The presented lane was the CPU/software one, where a PQ stream is shown RAW — the
|
||||
/// software path has no tone-map pass at all (the presenter uploads swscale RGBA
|
||||
/// as-is; the CSC mode-1 tonemap is hardware-lane only) — so the OSD badge reads
|
||||
/// `HDR→SDR (raw)` there instead of claiming a tone-map that never ran.
|
||||
hdr_untonemapped: bool,
|
||||
// Presenter-side 1 s window (design/stats-unification.md): end-to-end
|
||||
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
||||
win_e2e_us: Vec<u64>,
|
||||
@@ -308,6 +313,7 @@ impl StreamState {
|
||||
latch_grid,
|
||||
clock_offset: None,
|
||||
hdr: false,
|
||||
hdr_untonemapped: false,
|
||||
win_e2e_us: Vec::with_capacity(256),
|
||||
win_disp_us: Vec::with_capacity(256),
|
||||
win_start: Instant::now(),
|
||||
@@ -1103,6 +1109,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
st.hdr_untonemapped,
|
||||
st.profile.as_deref(),
|
||||
);
|
||||
if stats_verbosity != StatsVerbosity::Off {
|
||||
@@ -1115,6 +1122,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
st.hdr_untonemapped,
|
||||
st.profile.as_deref(),
|
||||
);
|
||||
println!("stats: {}", full.replace('\n', " | "));
|
||||
@@ -1296,6 +1304,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// HDR (PQ) pyrowave session presents through the HDR10 path exactly
|
||||
// like the H.26x codecs (design/pyrowave-444-hdr.md Phase 3).
|
||||
st.hdr = f.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::PyroWave(f),
|
||||
@@ -1323,6 +1332,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
}
|
||||
DecodedImage::Cpu(c) => {
|
||||
st.hdr = c.color.is_pq();
|
||||
// The software lane shows PQ raw (no tone-map pass exists there)
|
||||
// — the OSD badge must not claim `HDR→SDR` for it.
|
||||
st.hdr_untonemapped = true;
|
||||
presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1330,6 +1342,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
if presenter.supports_dmabuf() && !st.dmabuf_demoted =>
|
||||
{
|
||||
st.hdr = d.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::Dmabuf(d),
|
||||
@@ -1380,6 +1393,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
#[cfg(windows)]
|
||||
DecodedImage::D3d11(d) if presenter.supports_d3d11() && !st.dmabuf_demoted => {
|
||||
st.hdr = d.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::D3d11(d),
|
||||
@@ -1426,6 +1440,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// demotion contract as the dmabuf path.
|
||||
DecodedImage::VkFrame(v) if !st.dmabuf_demoted => {
|
||||
st.hdr = v.color.is_pq();
|
||||
st.hdr_untonemapped = false;
|
||||
match presenter.present(
|
||||
&window,
|
||||
FrameInput::VkFrame(v),
|
||||
@@ -1910,6 +1925,7 @@ fn bump_stats_tier(
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
st.hdr_untonemapped,
|
||||
st.profile.as_deref(),
|
||||
),
|
||||
None => String::new(),
|
||||
@@ -2007,11 +2023,15 @@ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
|
||||
///
|
||||
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
|
||||
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
|
||||
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
|
||||
/// format offered, HDR off in the compositor) shows `HDR→SDR`; and a PQ stream on the
|
||||
/// software-decode lane (`hdr_untonemapped`) shows `HDR→SDR (raw)` — that lane has no
|
||||
/// tone-map pass at all, so the washed-out picture is named for what it is rather than
|
||||
/// passed off as a tone-map.
|
||||
///
|
||||
/// `profile` (the session's settings profile, `None` for the global defaults) closes the
|
||||
/// first line at every tier — the cheapest possible answer to "which profile am I on?"
|
||||
/// (design/client-settings-profiles.md §5.2).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stats_text(
|
||||
verbosity: StatsVerbosity,
|
||||
mode_line: &str,
|
||||
@@ -2019,6 +2039,7 @@ fn stats_text(
|
||||
p: &PresentedWindow,
|
||||
hdr_stream: bool,
|
||||
hdr_display: bool,
|
||||
hdr_untonemapped: bool,
|
||||
profile: Option<&str>,
|
||||
) -> String {
|
||||
let profile_tag = profile.map(|n| format!(" · {n}")).unwrap_or_default();
|
||||
@@ -2068,6 +2089,7 @@ fn stats_text(
|
||||
if s.decoder.is_empty() { "-" } else { s.decoder },
|
||||
match (hdr_stream, hdr_display) {
|
||||
(true, true) => " · HDR",
|
||||
(true, false) if hdr_untonemapped => " · HDR→SDR (raw)",
|
||||
(true, false) => " · HDR→SDR",
|
||||
_ => "",
|
||||
},
|
||||
@@ -2380,7 +2402,7 @@ mod tests {
|
||||
#[test]
|
||||
fn stats_text_tiers() {
|
||||
let (s, p) = sample();
|
||||
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, None);
|
||||
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, false, None);
|
||||
|
||||
assert_eq!(text(StatsVerbosity::Off), "");
|
||||
|
||||
@@ -2397,6 +2419,10 @@ mod tests {
|
||||
|
||||
let detailed = text(StatsVerbosity::Detailed);
|
||||
assert!(detailed.contains("vulkan · HDR→SDR"));
|
||||
assert!(
|
||||
!detailed.contains("(raw)"),
|
||||
"the hardware lane tone-maps — no raw tag"
|
||||
);
|
||||
assert!(detailed.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms"));
|
||||
assert!(detailed.contains("host: queue 0.3 · encode 0.5 · xfer 0.1 · pace 0.3 ms"));
|
||||
assert!(detailed.contains("lost 3 (0.4%)"));
|
||||
@@ -2406,6 +2432,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The honest HDR badges: a PQ stream on the software-decode lane is shown WITHOUT
|
||||
/// tone-mapping (that lane has no PQ→sRGB pass), so its badge must not read as the
|
||||
/// hardware lane's `HDR→SDR` tone-map — and an HDR10 swapchain shows plain `HDR`
|
||||
/// whatever the lane claims (a CPU frame forces the swapchain to SDR anyway).
|
||||
#[test]
|
||||
fn hdr_badge_names_the_untonemapped_cpu_lane() {
|
||||
let (s, p) = sample();
|
||||
let badge = |hdr_display, raw| {
|
||||
stats_text(
|
||||
StatsVerbosity::Detailed,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
true,
|
||||
hdr_display,
|
||||
raw,
|
||||
None,
|
||||
)
|
||||
};
|
||||
assert!(badge(false, true).contains(" · HDR→SDR (raw)"));
|
||||
assert!(!badge(false, false).contains("(raw)"));
|
||||
assert!(badge(false, false).contains(" · HDR→SDR"));
|
||||
assert!(badge(true, false).contains(" · HDR"));
|
||||
assert!(!badge(true, false).contains("HDR→SDR"));
|
||||
}
|
||||
|
||||
/// Detailed shows the negotiated encoder target next to the measured rate — the
|
||||
/// figure whose absence let the settings-drop bug ship four releases — tagged
|
||||
/// `(auto)` when the ABR owns it, plus the honest chroma tag when 4:4:4 was asked.
|
||||
@@ -2413,7 +2465,7 @@ mod tests {
|
||||
fn detailed_shows_target_and_chroma_resolution() {
|
||||
let (mut s, p) = sample();
|
||||
let line1 = |s: &Stats, v| {
|
||||
stats_text(v, "m", s, &p, false, false, None)
|
||||
stats_text(v, "m", s, &p, false, false, false, None)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
@@ -2446,7 +2498,7 @@ mod tests {
|
||||
#[test]
|
||||
fn stats_text_mic_line() {
|
||||
let (mut s, p) = sample();
|
||||
let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, None);
|
||||
let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, false, None);
|
||||
assert!(
|
||||
!text(&s, StatsVerbosity::Detailed).contains("mic"),
|
||||
"no mic line while the mic is off"
|
||||
@@ -2473,7 +2525,16 @@ mod tests {
|
||||
s.lost = 0;
|
||||
let p = PresentedWindow::default();
|
||||
assert_eq!(
|
||||
stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false, None),
|
||||
stats_text(
|
||||
StatsVerbosity::Compact,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
),
|
||||
"120 fps · 24 Mb/s"
|
||||
);
|
||||
}
|
||||
@@ -2491,6 +2552,7 @@ mod tests {
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Some("Game")
|
||||
),
|
||||
"120 fps · 6.4 ms · 24 Mb/s · lost 3 · Game"
|
||||
@@ -2502,6 +2564,7 @@ mod tests {
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Some("Work"),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -2515,13 +2578,22 @@ mod tests {
|
||||
&p,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some("Work"),
|
||||
);
|
||||
assert!(detailed.lines().next().unwrap().ends_with("· HDR · Work"));
|
||||
// No profile → the line is exactly what it always was.
|
||||
assert!(
|
||||
!stats_text(StatsVerbosity::Normal, "m", &s, &p, false, false, None).contains(" · ")
|
||||
);
|
||||
assert!(!stats_text(
|
||||
StatsVerbosity::Normal,
|
||||
"m",
|
||||
&s,
|
||||
&p,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None
|
||||
)
|
||||
.contains(" · "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -40,7 +40,27 @@ impl Presenter {
|
||||
// PQ→sRGB pass.
|
||||
let frame_pq = match &input {
|
||||
FrameInput::Redraw => None,
|
||||
FrameInput::Cpu(_) => Some(false),
|
||||
FrameInput::Cpu(f) => {
|
||||
// The swapchain answer stays `false` (above) — but a PQ stream on this
|
||||
// lane is then shown RAW: no PQ→sRGB pass exists here (the CSC mode-1
|
||||
// tonemap is hardware-lane only; CPU frames are a straight RGBA upload),
|
||||
// so the picture is washed out and the pq-downgrade warn below never
|
||||
// fires. Say so once, or the only trace is an OSD badge. (A process-once
|
||||
// latch, same idiom as the decoders' first-frame layout dumps — the
|
||||
// condition is a property of the lane, not of one Presenter.)
|
||||
if f.color.is_pq() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static WARNED: AtomicBool = AtomicBool::new(false);
|
||||
if !WARNED.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"HDR10 (PQ) stream on the software-decode lane — it has no \
|
||||
PQ→sRGB pass, so the picture is shown untonemapped (washed \
|
||||
out). Hardware decode restores correct colour."
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
|
||||
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
||||
|
||||
@@ -731,12 +731,17 @@ pub(super) fn pick_formats(
|
||||
}
|
||||
|
||||
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
|
||||
/// fifo|mailbox|immediate` overrides). Both are tear-free, but an arrival-paced
|
||||
/// presenter must not block in FIFO's present queue: when the compositor holds images
|
||||
/// for a vblank pass (gamescope's composite path) or arrival cadence drifts against
|
||||
/// refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms added
|
||||
/// to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
|
||||
/// fifo|mailbox|immediate|fifo_relaxed` overrides). Both defaults are tear-free, but an
|
||||
/// arrival-paced presenter must not block in FIFO's present queue: when the compositor
|
||||
/// holds images for a vblank pass (gamescope's composite path) or arrival cadence drifts
|
||||
/// against refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms
|
||||
/// added to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
|
||||
/// pipeline stays at decode latency and a late frame is replaced, not waited for.
|
||||
///
|
||||
/// AMD's Windows driver offers no MAILBOX (NVIDIA does), so those clients land on FIFO —
|
||||
/// expected, not a client misconfiguration. FIFO_RELAXED is opt-in only: it tears exactly
|
||||
/// when a stream frame misses the vblank it was pacing for, which on a drifting arrival
|
||||
/// cadence is often — a trade the user must choose, never a silent fallback.
|
||||
fn pick_present_mode(
|
||||
surface_i: &ash::khr::surface::Instance,
|
||||
pdev: vk::PhysicalDevice,
|
||||
@@ -748,7 +753,15 @@ fn pick_present_mode(
|
||||
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
|
||||
Some("fifo") => vk::PresentModeKHR::FIFO,
|
||||
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
|
||||
_ => vk::PresentModeKHR::MAILBOX,
|
||||
Some("fifo_relaxed") => vk::PresentModeKHR::FIFO_RELAXED,
|
||||
Some("mailbox") | None => vk::PresentModeKHR::MAILBOX,
|
||||
Some(other) => {
|
||||
tracing::warn!(
|
||||
value = other,
|
||||
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — using mailbox"
|
||||
);
|
||||
vk::PresentModeKHR::MAILBOX
|
||||
}
|
||||
};
|
||||
Ok(if modes.contains(&want) {
|
||||
want
|
||||
|
||||
@@ -407,13 +407,21 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
|
||||
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
|
||||
let _ = compositor;
|
||||
// `ensure_available` self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
|
||||
// devnode present, interface gone): one device cycle + re-probe before giving up.
|
||||
anyhow::ensure!(
|
||||
driver::ensure_available(),
|
||||
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
|
||||
not loaded (the host installer bundles it; reinstall or check the driver state)"
|
||||
);
|
||||
// `ensure_available` waits out a devnode that is merely coming up (the wake-from-sleep case:
|
||||
// the adapter re-enters D0 and re-registers its interface while a reconnecting client is
|
||||
// already knocking) and self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
|
||||
// devnode present, interface gone) by reloading the adapter.
|
||||
//
|
||||
// `context`, not a replacement message: it reports WHY — how long it waited, whether a reload
|
||||
// ran, how many interface instances were seen and in what state. A flat "the driver is not
|
||||
// installed" is what a field report carried from a box whose driver was installed, started,
|
||||
// and simply mid-resume, and it pointed every reader at the wrong problem.
|
||||
use anyhow::Context as _;
|
||||
driver::ensure_available().context(
|
||||
"pf-vdisplay driver interface not available — the pf-vdisplay IddCx driver is not \
|
||||
installed, not loaded, or did not finish coming back up (the host installer bundles \
|
||||
it; reinstall or check the driver state)",
|
||||
)?;
|
||||
Ok(Box::new(driver::PfVdisplayDisplay::new()?))
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
|
||||
@@ -138,7 +138,14 @@ impl KwinDisplay {
|
||||
let kind = match topology {
|
||||
Topology::Exclusive => TopologyKind::Exclusive,
|
||||
Topology::Primary => TopologyKind::Primary,
|
||||
Topology::Extend | Topology::Auto => return Vec::new(),
|
||||
Topology::Extend | Topology::Auto => {
|
||||
// No topology to apply — but the output must still be its OWN desktop rather than a
|
||||
// mirror of someone's panel, and KWin restores a stored `replicationSource` onto our
|
||||
// (stable) output name for whatever monitor set it was saved under. Applies only if
|
||||
// it really is mirroring; nothing else about the user's arrangement is touched.
|
||||
crate::kwin_output_mgmt::clear_replication_source(our_prefix, dims.0, dims.1);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
// In-process over Wayland — immune to whatever wedges the standalone kscreen-doctor.
|
||||
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
|
||||
|
||||
@@ -112,6 +112,34 @@ const POLL_MS: i32 = 100;
|
||||
/// asked — matches `kwin::CVT_H_GRANULARITY`. Used when matching the generated mode back.
|
||||
const CVT_H_GRANULARITY: u32 = 8;
|
||||
|
||||
/// `kde_output_management_v2.set_replication_source` (and the device's `replication_source` event)
|
||||
/// arrived in v13. wayland-rs does not range-check requests, so sending one to a lower-version bind
|
||||
/// would be a protocol error that kills the connection — every call site gates on this.
|
||||
const REPLICATION_SOURCE_SINCE: u32 = 13;
|
||||
|
||||
/// The `source` value that means "this output mirrors nothing" — KWin's `applyMirroring` looks the
|
||||
/// source UUID up among the enabled outputs and treats an EMPTY string as no replication at all.
|
||||
const NO_REPLICATION_SOURCE: &str = "";
|
||||
|
||||
/// Is this output currently a MIRROR of another one?
|
||||
///
|
||||
/// KWin persists output config per *setup* — the exact set of connected outputs, matched by
|
||||
/// EDID/connector — in `kwinoutputconfig.json`, and `replicationSource` is one of the fields it
|
||||
/// stores and restores (`OutputConfigurationStore::storeConfig` / `setupToConfig`). Our virtual
|
||||
/// output carries a STABLE name across sessions (that is deliberate — KWin keys per-output scale by
|
||||
/// it), so a stored `replicationSource` for that name is re-applied to OUR output on every session
|
||||
/// that reproduces the same monitor set. The output then shows the source's viewport instead of
|
||||
/// being its own desktop, which is the whole point of creating it — and per the protocol's own note
|
||||
/// on `priority`, "an output may not be in the output order if it's disabled **or mirroring another
|
||||
/// screen**", so the primary assertion silently stops meaning anything too.
|
||||
///
|
||||
/// The event carries an empty string for the ordinary case, so `Some("")` must read as "not
|
||||
/// mirroring" — treating the mere presence of the event as a mirror would de-mirror every output on
|
||||
/// every apply.
|
||||
fn is_mirroring(replication_source: Option<&str>) -> bool {
|
||||
replication_source.is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Which topology to apply once our output is resolved.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TopologyKind {
|
||||
@@ -154,6 +182,9 @@ struct DeviceState {
|
||||
scale: Option<f64>,
|
||||
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
|
||||
priority: Option<u32>,
|
||||
/// UUID of the output this one MIRRORS, from the `replication_source` event (device ≥ v13).
|
||||
/// Empty / `None` ⇒ it is its own desktop. See [`is_mirroring`] for why this matters to us.
|
||||
replication_source: Option<String>,
|
||||
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
|
||||
current_mode: Option<ObjectId>,
|
||||
/// Every mode this output advertised, in announce order — `(mode object id, proxy)` — so restore
|
||||
@@ -307,6 +338,7 @@ impl Dispatch<OutputDevice, u32> for State {
|
||||
DeviceEvent::Scale { factor } => entry.scale = Some(factor),
|
||||
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
|
||||
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
|
||||
DeviceEvent::ReplicationSource { source } => entry.replication_source = Some(source),
|
||||
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
|
||||
DeviceEvent::Mode { mode } => entry.modes.push((mode.id(), mode)),
|
||||
DeviceEvent::Done => entry.seen_done = true,
|
||||
@@ -626,6 +658,17 @@ pub(crate) fn apply_topology(
|
||||
};
|
||||
let our_uuid = ours.uuid.clone();
|
||||
let our_id = ours.proxy.as_ref().map(|p| p.id());
|
||||
if is_mirroring(ours.replication_source.as_deref()) {
|
||||
// Worth a line of its own: this is the state a user experiences as "the stream just shows my
|
||||
// monitor", and it comes from KWin's stored config for THIS monitor set, so it reproduces
|
||||
// every session until something clears it. The config below does.
|
||||
tracing::warn!(
|
||||
source_uuid = ?ours.replication_source,
|
||||
our_prefix,
|
||||
"KWin had our streamed output MIRRORING another screen (a stored kwinoutputconfig.json \
|
||||
replicationSource for this monitor set) — clearing it so the output is its own desktop"
|
||||
);
|
||||
}
|
||||
|
||||
// First-slot-wins (§6.1): don't steal primary if another managed sibling already holds it
|
||||
// (priority 1) — a 2nd exclusive session joins as a secondary of the shared desktop. A
|
||||
@@ -681,6 +724,17 @@ pub(crate) fn apply_topology(
|
||||
let config = sess.new_config();
|
||||
if let Some(proxy) = ours.proxy.as_ref() {
|
||||
config.enable(proxy, 1);
|
||||
// State that ours is its OWN desktop, not a replica of somebody's panel. A stored
|
||||
// `replicationSource` for our (stable) output name is re-applied by KWin on every session
|
||||
// that reproduces the same monitor set, and it survives everything else this config says:
|
||||
// enabling and prioritising a mirror still leaves it showing the source's viewport, scaled
|
||||
// to the source's size (`OutputConfigurationStore::applyMirroring`). See [`is_mirroring`].
|
||||
// Unconditional rather than conditional on what we enumerated: KWin may apply the stored
|
||||
// setup config between our enumerate and this apply, and clearing a source that is already
|
||||
// empty is exactly what KWin does for a non-mirroring output anyway.
|
||||
if mgmt_version >= REPLICATION_SOURCE_SINCE {
|
||||
config.set_replication_source(proxy, NO_REPLICATION_SOURCE.to_string());
|
||||
}
|
||||
if !sibling_is_primary {
|
||||
config.set_primary_output(proxy);
|
||||
if mgmt_version >= 3 {
|
||||
@@ -781,6 +835,68 @@ pub(crate) fn apply_topology(
|
||||
}
|
||||
}
|
||||
|
||||
/// De-mirror the just-created virtual output (name starts with `our_prefix`, current size
|
||||
/// `our_w`×`our_h`) **without touching the rest of the topology** — the `Extend`/`Auto` counterpart
|
||||
/// to the clear [`apply_topology`] folds into its own config.
|
||||
///
|
||||
/// Those topologies deliberately issue no output-management calls: the streamed output is meant to
|
||||
/// join the desk as one more head, and re-arranging the user's screens would be the rudeness the
|
||||
/// setting exists to avoid. But a stored `replicationSource` (see [`is_mirroring`]) is not an
|
||||
/// arrangement — it makes our output show a *physical panel's* viewport instead of its own desktop,
|
||||
/// which is broken under every topology equally. So this reads the state and applies **only** when
|
||||
/// our output really is mirroring; the ordinary session pays one bounded enumerate and no apply.
|
||||
pub(crate) fn clear_replication_source(our_prefix: &str, our_w: u32, our_h: u32) {
|
||||
let Some(mut sess) = Session::open() else {
|
||||
return;
|
||||
};
|
||||
let deadline = Instant::now() + OP_BUDGET;
|
||||
let mgmt_version = sess
|
||||
.state
|
||||
.mgmt_name_version
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or_default();
|
||||
if mgmt_version < REPLICATION_SOURCE_SINCE {
|
||||
return;
|
||||
}
|
||||
// Same resolve as `apply_topology`: managed-prefix name AND the birth size, newest global wins.
|
||||
let Some(ours) = sess
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| {
|
||||
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
|
||||
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
|
||||
})
|
||||
.max_by_key(|d| d.global)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !is_mirroring(ours.replication_source.as_deref()) {
|
||||
return;
|
||||
}
|
||||
let Some(proxy) = ours.proxy.as_ref() else {
|
||||
return;
|
||||
};
|
||||
tracing::warn!(
|
||||
source_uuid = ?ours.replication_source,
|
||||
our_prefix,
|
||||
"KWin had our streamed output MIRRORING another screen (a stored kwinoutputconfig.json \
|
||||
replicationSource for this monitor set) — clearing it so the output is its own desktop"
|
||||
);
|
||||
let config = sess.new_config();
|
||||
config.set_replication_source(proxy, NO_REPLICATION_SOURCE.to_string());
|
||||
let ok = sess.apply(&config, deadline);
|
||||
config.destroy();
|
||||
if !ok {
|
||||
tracing::warn!(
|
||||
reason = ?sess.state.failure_reason,
|
||||
"KWin output management: could not clear the streamed output's replication source — \
|
||||
the stream will show the mirrored screen's content"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install + select a `want_w`×`want_h`@`want_hz` custom mode on the just-created virtual output
|
||||
/// (name starts with `our_prefix`, currently at its sacrificial birth size `birth_w`×`birth_h`) —
|
||||
/// entirely over `kde_output_management_v2`, the in-process replacement for the `kscreen-doctor`
|
||||
@@ -1010,6 +1126,37 @@ fn find_mode(sess: &Session, dev: &DeviceState, spec: &str) -> Option<DeviceMode
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// KWin sends `replication_source` with an EMPTY string for the ordinary, non-mirroring output.
|
||||
/// Reading the event's mere presence as "mirroring" would make every apply issue a pointless
|
||||
/// de-mirror — and, worse, would make the warn fire on every healthy session.
|
||||
#[test]
|
||||
fn an_empty_replication_source_is_not_mirroring() {
|
||||
assert!(!is_mirroring(None));
|
||||
assert!(!is_mirroring(Some("")));
|
||||
}
|
||||
|
||||
/// A real source UUID is the state the field report describes: the streamed output shows a
|
||||
/// physical panel's viewport instead of its own desktop.
|
||||
#[test]
|
||||
fn a_uuid_replication_source_is_mirroring() {
|
||||
assert!(is_mirroring(Some("f7a3c1e2-0b44-4c19-9a1d-6f2b8e0c5d31")));
|
||||
}
|
||||
|
||||
/// The clear we send must be the value KWin reads as "mirrors nothing" — an empty source, which
|
||||
/// its `applyMirroring` fails to resolve to any enabled output and so treats as no replication.
|
||||
#[test]
|
||||
fn the_clear_value_is_the_empty_source() {
|
||||
assert!(!is_mirroring(Some(NO_REPLICATION_SOURCE)));
|
||||
}
|
||||
|
||||
/// The request/event pair is `since 13`; wayland-rs does not range-check requests, so a bind
|
||||
/// below this must never reach `set_replication_source` (it would be a fatal protocol error).
|
||||
#[test]
|
||||
fn replication_source_version_gate_matches_the_protocol() {
|
||||
assert_eq!(REPLICATION_SOURCE_SINCE, 13);
|
||||
const { assert!(MGMT_MAX >= REPLICATION_SOURCE_SINCE) };
|
||||
}
|
||||
|
||||
/// The `WxH@Hz` capture rounds mHz to whole Hz — the shape teardown parses back.
|
||||
#[test]
|
||||
fn mode_spec_rounds_millihertz() {
|
||||
|
||||
@@ -425,6 +425,20 @@ pub fn control_device_handle() -> Option<HANDLE> {
|
||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||
}
|
||||
|
||||
/// Retire the cached control handle from OUTSIDE the manager, for a caller that KNOWS the device
|
||||
/// died — the adapter-reload recovery in [`crate::driver`], which tears the driver stack down and
|
||||
/// back up. Without it the stale handle survives into the next session's `IOCTL_ADD` and is only
|
||||
/// recovered by the gone-classified retry one failed IOCTL later.
|
||||
///
|
||||
/// Takes the `device` mutex, so it must NOT be called from inside it (notably not from
|
||||
/// `VdisplayDriver::open`, which `ensure_device` invokes while holding it). No-op before any backend
|
||||
/// opened the device.
|
||||
pub(crate) fn invalidate_cached_device(why: &str) {
|
||||
if let Some(m) = VDM.get() {
|
||||
m.invalidate_device(&anyhow::anyhow!("{why}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-commit the CURRENT display config under the manager `state` lock (the sole-topology-mutator
|
||||
/// contract of [`force_mode_reenumeration`]). The secure-desktop guard's actuator: the OS only
|
||||
/// reverts a path to its software-cursor default ON a mode commit, so standing the hardware-cursor
|
||||
|
||||
@@ -21,6 +21,7 @@ use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
@@ -143,31 +144,70 @@ fn reap_ghost_monitors() -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick the pf-vdisplay ADAPTER device (disable → enable) — the in-process equivalent of
|
||||
/// `reset-pf-vdisplay.ps1` step 3. A crashed/killed WUDFHost can leave the devnode "started" yet
|
||||
/// HOSTLESS (PnP Status OK, no WUDFHost process, zero device-interface instances) — a zombie no
|
||||
/// session can open until the stack reloads; on-glass, only a device cycle recovered it. Called by
|
||||
/// [`VdisplayDriver::open`] when `open_device` finds no openable interface; the caller retries the
|
||||
/// open afterwards. Best-effort + bounded (~7 s inside the script). Returns whether a punktfunk
|
||||
/// adapter devnode was found (and therefore cycled) — `false` means the driver genuinely is not
|
||||
/// installed and a retry is pointless.
|
||||
fn restart_vdisplay_device() -> bool {
|
||||
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
|
||||
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
|
||||
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
|
||||
/// woken host logged `cycled … status=OK` and then failed the session for a missing interface).
|
||||
enum AdapterCycle {
|
||||
/// The driver stack was genuinely reloaded. `how` names the lever that worked.
|
||||
Reloaded { how: &'static str, status: String },
|
||||
/// No punktfunk adapter devnode exists at all — the driver is not installed and retrying is
|
||||
/// pointless.
|
||||
NotInstalled,
|
||||
/// A devnode exists but could not be reloaded; carries the reason (already whitespace-collapsed).
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1`
|
||||
/// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no
|
||||
/// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack
|
||||
/// reloads; on-glass, only a device reload recovered it.
|
||||
///
|
||||
/// Two levers, in order. `Disable-PnpDevice` + `Enable-PnpDevice` is the one `reset-pf-vdisplay.ps1`
|
||||
/// uses — but that script stops the host service FIRST, precisely because the host holds the driver's
|
||||
/// control device open (its step 1), and a disable can be refused for a device in use. This runs
|
||||
/// INSIDE the host, so it structurally cannot take that step: the retired-but-never-closed handles in
|
||||
/// [`DeviceSlot`](super::manager) are still open on the very device being disabled. So a refusal is
|
||||
/// the expected case here, not the exotic one, and `pnputil /restart-device` — which reloads a device
|
||||
/// that is in use — is the fallback. Whichever runs, the failure paths re-enable, so a half-completed
|
||||
/// cycle can never leave the adapter DISABLED.
|
||||
///
|
||||
/// Best-effort + bounded (~6 s inside the script).
|
||||
fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
// Mirrors reset-pf-vdisplay.ps1's Get-PfAdapter selector ('punktfunk Virtual Display' is the INF
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above.
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
|
||||
// reported tokens are ours, so parsing them is locale-invariant too.
|
||||
//
|
||||
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
|
||||
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
|
||||
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
|
||||
// reading `OK`, so the host logged a successful recovery it had never performed.
|
||||
//
|
||||
// `$LASTEXITCODE = 1` before the pnputil call for the same reason: no native command runs before
|
||||
// it, so an unlaunchable pnputil would otherwise leave the variable holding whatever it held and
|
||||
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
|
||||
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
|
||||
// System32.
|
||||
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
|
||||
if ($ad) { \
|
||||
Disable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
|
||||
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status; \
|
||||
if ($st -ne 'OK') { Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 2; \
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status }; \
|
||||
Write-Output $st \
|
||||
} else { Write-Output 'ABSENT' }";
|
||||
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
|
||||
$id = $ad.InstanceId; $err = ''; \
|
||||
try { \
|
||||
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
|
||||
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
|
||||
catch { Start-Sleep -Seconds 2; Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop }; \
|
||||
Start-Sleep -Seconds 2; \
|
||||
Write-Output ('RELOADED cycle ' + (Get-PnpDevice -InstanceId $id).Status); exit \
|
||||
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
||||
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
|
||||
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
|
||||
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
|
||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
match std::process::Command::new(&ps)
|
||||
let out = match std::process::Command::new(&ps)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
@@ -178,22 +218,65 @@ fn restart_vdisplay_device() -> bool {
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if status == "ABSENT" {
|
||||
tracing::warn!("pf-vdisplay: no adapter devnode to cycle — driver not installed");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
%status,
|
||||
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
|
||||
);
|
||||
}
|
||||
status != "ABSENT"
|
||||
}
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: adapter cycle could not spawn powershell");
|
||||
false
|
||||
tracing::warn!(error = %e, "pf-vdisplay: adapter reload could not spawn powershell");
|
||||
return AdapterCycle::Refused(format!("could not spawn powershell: {e}"));
|
||||
}
|
||||
};
|
||||
let outcome = classify_reload_output(&out);
|
||||
match &outcome {
|
||||
AdapterCycle::NotInstalled => {
|
||||
tracing::warn!("pf-vdisplay: no adapter devnode to reload — driver not installed");
|
||||
}
|
||||
AdapterCycle::Reloaded { how, status } => tracing::warn!(
|
||||
how,
|
||||
%status,
|
||||
"pf-vdisplay: reloaded the adapter device (hostless-zombie recovery)"
|
||||
),
|
||||
AdapterCycle::Refused(why) => tracing::warn!(
|
||||
reason = %why,
|
||||
"pf-vdisplay: the adapter devnode exists but could NOT be reloaded — a session cannot \
|
||||
recover from this without a host-service restart or a reboot"
|
||||
),
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Parse [`reload_vdisplay_adapter`]'s script output. Split out to be testable without a box: the
|
||||
/// bug this whole change answers was a recovery that MISreported its own outcome, so the decoding of
|
||||
/// that outcome is worth pinning down.
|
||||
fn classify_reload_output(out: &str) -> AdapterCycle {
|
||||
let out = out.trim();
|
||||
let (verb, rest) = out.split_once(char::is_whitespace).unwrap_or((out, ""));
|
||||
match verb {
|
||||
"ABSENT" => AdapterCycle::NotInstalled,
|
||||
"RELOADED" => {
|
||||
let (how, status) = rest
|
||||
.trim()
|
||||
.split_once(char::is_whitespace)
|
||||
.unwrap_or((rest.trim(), ""));
|
||||
// Held as `&'static str` so the two levers stay distinguishable in a field report:
|
||||
// `restart` means the disable was refused, i.e. something still holds the device open —
|
||||
// worth knowing when a reload does not fix the box.
|
||||
let how: &'static str = if how == "restart" {
|
||||
"pnputil /restart-device"
|
||||
} else {
|
||||
"disable+enable"
|
||||
};
|
||||
AdapterCycle::Reloaded {
|
||||
how,
|
||||
status: status.trim().to_string(),
|
||||
}
|
||||
}
|
||||
// Covers `REFUSED <reason>` and anything unrecognised, including an empty stdout (powershell
|
||||
// died before writing). All of them mean an un-reloaded devnode, which is the only thing
|
||||
// callers act on; the text rides along for the log.
|
||||
_ => AdapterCycle::Refused(if rest.trim().is_empty() {
|
||||
format!("unexpected adapter-reload output: {out:?}")
|
||||
} else {
|
||||
rest.trim().to_string()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +408,55 @@ impl Drop for DevInfoList {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a device-interface enumeration found. The counts are what let [`ensure_available`] tell a
|
||||
/// devnode that is MID-TRANSITION (present, interface registered, not started yet — resuming from
|
||||
/// sleep, restarting, reloading) apart from one that is genuinely gone. Only the second is worth
|
||||
/// answering with device surgery; cycling the first only lengthens the outage it is waiting out.
|
||||
struct Probe {
|
||||
/// The control handle, if any interface instance opened.
|
||||
handle: Option<OwnedHandle>,
|
||||
/// Instances seen with `SPINT_ACTIVE` set — the owning device is started.
|
||||
active: u32,
|
||||
/// Instances seen with `SPINT_ACTIVE` clear — registered, but the owning device is not started.
|
||||
inactive: u32,
|
||||
/// The last enumeration/open failure, kept for the diagnostic.
|
||||
last_err: Option<anyhow::Error>,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
/// No interface instance of ANY kind. With an adapter devnode present this is the hostless-zombie
|
||||
/// state a WUDFHost crash leaves; with none, the driver is not installed. Either way, waiting
|
||||
/// alone will not fix it.
|
||||
fn is_absent(&self) -> bool {
|
||||
self.handle.is_none() && self.active == 0 && self.inactive == 0
|
||||
}
|
||||
|
||||
/// Why no handle came back, NAMING what was seen — "0 interfaces" and "1 inactive interface" are
|
||||
/// completely different diagnoses (not installed vs. still coming up), and the old message
|
||||
/// collapsed both into "is the driver installed?". Call only on a miss; a hit reports as much.
|
||||
fn into_error(self) -> anyhow::Error {
|
||||
let seen = format!("{} active, {} inactive", self.active, self.inactive);
|
||||
if self.handle.is_some() {
|
||||
return anyhow::anyhow!("pf-vdisplay device interface opened ({seen})");
|
||||
}
|
||||
match self.last_err {
|
||||
Some(e) => e.context(format!("no openable pf-vdisplay device interface ({seen})")),
|
||||
None => anyhow::anyhow!(
|
||||
"no pf-vdisplay device interface found ({seen}) — is the pf-vdisplay driver \
|
||||
installed and its device started?"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume into the [`open_device`] result.
|
||||
fn into_result(mut self) -> Result<OwnedHandle> {
|
||||
match self.handle.take() {
|
||||
Some(h) => Ok(h),
|
||||
None => Err(self.into_error()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the pf-vdisplay control device.
|
||||
///
|
||||
/// SAFE, and owning. It has no caller obligation — it takes no arguments and every precondition is
|
||||
@@ -333,26 +465,40 @@ impl Drop for DevInfoList {
|
||||
/// this file has already leaked from once (see the wrap-IMMEDIATELY comment in `open`). Returning an
|
||||
/// `OwnedHandle` makes the close a `Drop`, so there is exactly one way to get it wrong: not at all.
|
||||
fn open_device() -> Result<OwnedHandle> {
|
||||
probe_device().into_result()
|
||||
}
|
||||
|
||||
/// [`open_device`], reporting WHAT it found rather than only whether it succeeded.
|
||||
fn probe_device() -> Probe {
|
||||
let mut probe = Probe {
|
||||
handle: None,
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
last_err: None,
|
||||
};
|
||||
// SAFETY: plain SetupAPI enumeration call; the returned list is solely owned by the RAII wrapper.
|
||||
let hdev = DevInfoList(
|
||||
unsafe {
|
||||
SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
let hdev = match unsafe {
|
||||
SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
}
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")
|
||||
{
|
||||
Ok(h) => DevInfoList(h),
|
||||
Err(e) => {
|
||||
probe.last_err = Some(e);
|
||||
return probe;
|
||||
}
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?,
|
||||
);
|
||||
};
|
||||
|
||||
// Enumerate EVERY interface instance, not just index 0: after a driver upgrade a present-but-
|
||||
// failed devnode (Code 10) can hold index 0 while the LIVE node's interface sits at a later
|
||||
// index — the old single-index read then failed every session with "driver not installed"
|
||||
// even though a working interface existed. `SPINT_ACTIVE` filters dead interfaces (an interface
|
||||
// is active only while its owning device is started); the first active + openable one wins.
|
||||
let mut inactive = 0u32;
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for index in 0..64u32 {
|
||||
let mut idata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
@@ -367,9 +513,10 @@ fn open_device() -> Result<OwnedHandle> {
|
||||
break; // ERROR_NO_MORE_ITEMS — no further candidates
|
||||
}
|
||||
if idata.Flags & SPINT_ACTIVE == 0 {
|
||||
inactive += 1;
|
||||
probe.inactive += 1;
|
||||
continue;
|
||||
}
|
||||
probe.active += 1;
|
||||
let mut required = 0u32;
|
||||
// SAFETY: sizing call — null buffer plus a valid `required` out-param; the expected
|
||||
// ERROR_INSUFFICIENT_BUFFER "failure" is ignored and only `required` is consumed.
|
||||
@@ -409,20 +556,18 @@ fn open_device() -> Result<OwnedHandle> {
|
||||
})
|
||||
};
|
||||
match opened {
|
||||
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing else
|
||||
// holds it, so transferring it into the `OwnedHandle` gives it a single owner that
|
||||
// closes it exactly once on drop.
|
||||
Ok(h) => return Ok(unsafe { OwnedHandle::from_raw_handle(h.0 as _) }),
|
||||
Ok(h) => {
|
||||
// SAFETY: `h` is the handle `CreateFileW` just returned to THIS call and nothing
|
||||
// else holds it, so transferring it into the `OwnedHandle` gives it a single owner
|
||||
// that closes it exactly once on drop.
|
||||
probe.handle = Some(unsafe { OwnedHandle::from_raw_handle(h.0 as _) });
|
||||
return probe;
|
||||
}
|
||||
// A raced-away or wedged device — remember the error, try the next interface.
|
||||
Err(e) => last_err = Some(e),
|
||||
Err(e) => probe.last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no ACTIVE pf-vdisplay device interface found ({inactive} inactive) — is the \
|
||||
pf-vdisplay driver installed and its device started?"
|
||||
)
|
||||
}))
|
||||
probe
|
||||
}
|
||||
|
||||
/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager)
|
||||
@@ -435,29 +580,14 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
}
|
||||
|
||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
|
||||
let device = match open_device() {
|
||||
Ok(d) => d,
|
||||
Err(first) => {
|
||||
// No openable interface. If a WUDFHost crash left the devnode a hostless zombie
|
||||
// (validated on-glass: PnP Status OK, zero interface instances), a device cycle
|
||||
// reloads the stack — kick it once and retry the open over a short arrival window.
|
||||
if !restart_vdisplay_device() {
|
||||
return Err(first); // no adapter devnode at all — genuinely not installed
|
||||
}
|
||||
let mut reopened = Err(first);
|
||||
for _ in 0..8 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
match open_device() {
|
||||
Ok(d) => {
|
||||
reopened = Ok(d);
|
||||
break;
|
||||
}
|
||||
Err(e) => reopened = Err(e),
|
||||
}
|
||||
}
|
||||
reopened.context("pf-vdisplay interface still absent after an adapter cycle")?
|
||||
}
|
||||
};
|
||||
// A short re-probe, and deliberately NO adapter reload — this replaces the second, impatient
|
||||
// copy of the recovery that used to live here. Session bring-up already ran the full
|
||||
// `ensure_available` before constructing the backend, so anything left for this open to
|
||||
// absorb is a race, not a wedge. `hw_cursor_capable` also lands here, mid client handshake,
|
||||
// where a reload's tens of seconds would be entirely the wrong trade for one capability bool
|
||||
// — and where reloading would deadlock besides, since `ensure_device` calls us holding the
|
||||
// manager's `device` mutex (see the `RECOVERY` ordering contract).
|
||||
let device = wait_for_interface(BRIEF_RETRY, false).0?;
|
||||
// `open_device` hands back an `OwnedHandle`, so every `?` below closes the device exactly
|
||||
// once by construction — the shape this used to reach by wrapping the raw handle here, and
|
||||
// which leaked whenever GET_INFO itself failed before that wrap was moved up.
|
||||
@@ -879,25 +1009,159 @@ pub fn is_available() -> bool {
|
||||
open_device().is_ok()
|
||||
}
|
||||
|
||||
/// [`is_available`], with self-heal: an interface-less driver whose adapter devnode EXISTS is the
|
||||
/// hostless-zombie state a WUDFHost crash leaves behind (validated on-glass — PnP reports Status OK
|
||||
/// with no WUDFHost process and zero interface instances, and every session fails at this gate until
|
||||
/// the device reloads). Cycle the adapter once and re-probe over a short arrival window. A genuinely
|
||||
/// uninstalled driver (no adapter devnode) fails fast without the wait.
|
||||
pub fn ensure_available() -> bool {
|
||||
if is_available() {
|
||||
return true;
|
||||
/// How often the interface is re-probed while waiting.
|
||||
const PROBE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long a devnode whose interface exists but is NOT-READY (no active instance, or `CreateFileW`
|
||||
/// refused) is given to come up on its own before the adapter is reloaded.
|
||||
///
|
||||
/// This is the wake-from-sleep window. Resuming re-enters D0 and re-registers the interface while
|
||||
/// the rest of the resume storm is still running, and a client reconnecting a second after wake
|
||||
/// arrives inside that gap — which the old code, probing exactly ONCE, answered by disabling and
|
||||
/// re-enabling a display adapter that was seconds from being ready anyway.
|
||||
const NOT_READY_GRACE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How long a fully ABSENT interface is given before the adapter is reloaded. Short — a hostless
|
||||
/// devnode does not heal itself, and that is the case this recovery exists for — but non-zero, so a
|
||||
/// resume that briefly de-registers the interface is not met with device surgery either.
|
||||
const ABSENT_SETTLE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long the interface is given to ARRIVE after a reload.
|
||||
///
|
||||
/// Was 4 s, which a quiet box meets and a box still finishing a resume does not: PnP is contended
|
||||
/// right after wake. Field report 2026-08-02 — a woken host logged a successful adapter cycle and
|
||||
/// then failed the session 4 s later for a missing interface, and the client could not connect.
|
||||
const ARRIVAL_AFTER_RELOAD: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Hard ceiling on the whole wait, so display prep can never block for an unbounded sum of the
|
||||
/// windows above. Without it a devnode wedged NOT-READY costs the full grace, then the reload, then
|
||||
/// the full arrival window before failing — the pathological case paying nearly a minute per session.
|
||||
/// Patience for a device that is coming back is the point; patience for one that never will is not.
|
||||
const TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The budget a caller that must NOT stall gives the interface: no adapter reload, just a short
|
||||
/// re-probe to ride out a race. [`VdisplayDriver::open`] uses it — by the time the manager opens,
|
||||
/// session bring-up has already run the full [`ensure_available`] above, and the OTHER path that
|
||||
/// reaches it (`manager::hw_cursor_capable`, a best-effort capability answer during the client
|
||||
/// handshake) must never hold the Welcome for tens of seconds to decide one bool.
|
||||
const BRIEF_RETRY: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Serializes the recovery so N sessions racing in after a wake perform ONE adapter reload between
|
||||
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
|
||||
/// on. The second caller through typically finds the interface already up and returns at once.
|
||||
///
|
||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
|
||||
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
|
||||
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
|
||||
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
|
||||
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
|
||||
/// wake from sleep.
|
||||
///
|
||||
/// Returns the reason on failure instead of a bare `false`: the caller used to replace it with a
|
||||
/// flat "the driver is not installed", which is what a field report showed on a box whose driver was
|
||||
/// installed, started, and merely mid-resume.
|
||||
pub fn ensure_available() -> Result<()> {
|
||||
// Poisoning carries no meaning here — the guard protects a `()`, not state a panic could leave
|
||||
// inconsistent — so a previous panic must not wedge every later session out of recovery.
|
||||
let (result, reloaded) = {
|
||||
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
wait_for_interface(NOT_READY_GRACE, true)
|
||||
};
|
||||
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
|
||||
// stack down and back up, so any control handle a previous session cached is dead by
|
||||
// construction — retire it while we know that for certain, rather than leaving the next session
|
||||
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
|
||||
if reloaded {
|
||||
super::manager::invalidate_cached_device(
|
||||
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
|
||||
);
|
||||
}
|
||||
if !restart_vdisplay_device() {
|
||||
return false;
|
||||
}
|
||||
for _ in 0..8 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
if is_available() {
|
||||
return true;
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
/// Wait for an openable control interface, reloading the adapter if `reload` and the devnode looks
|
||||
/// genuinely hostless. Returns the handle (so the manager's own open can keep it) alongside whether
|
||||
/// a reload ran.
|
||||
///
|
||||
/// Two distinguishable states hide behind "cannot open the interface", and they want opposite
|
||||
/// treatment:
|
||||
///
|
||||
/// * **Not ready** — instances are registered but none is active (or the open is refused). The
|
||||
/// devnode is THERE and coming up: resuming from sleep, restarting, reloading. It heals itself;
|
||||
/// reloading the adapter underneath it only lengthens the outage.
|
||||
/// * **Absent** — no instance at all. With an adapter devnode present this is the hostless-zombie
|
||||
/// state a WUDFHost crash leaves (validated on-glass: PnP Status OK, no WUDFHost process, zero
|
||||
/// interface instances). Only a reload clears it.
|
||||
///
|
||||
/// So: probe, wait out a not-ready device, reload an absent one after a short settle, and give the
|
||||
/// interface a real arrival window afterwards. A reload is still attempted once at the end of
|
||||
/// `not_ready_grace`, so a devnode wedged not-ready (a failed start) recovers exactly as it did
|
||||
/// before. A genuinely uninstalled driver — no adapter devnode — still fails FAST, with no wait.
|
||||
fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedHandle>, bool) {
|
||||
let started = Instant::now();
|
||||
let mut deadline = started + not_ready_grace;
|
||||
let mut absent_since: Option<Instant> = None;
|
||||
let mut reloaded = false;
|
||||
loop {
|
||||
let mut probe = probe_device();
|
||||
if let Some(h) = probe.handle.take() {
|
||||
if reloaded || started.elapsed() > PROBE_INTERVAL {
|
||||
tracing::info!(
|
||||
waited_ms = started.elapsed().as_millis() as u64,
|
||||
reloaded,
|
||||
"pf-vdisplay: control interface available"
|
||||
);
|
||||
}
|
||||
return (Ok(h), reloaded);
|
||||
}
|
||||
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
|
||||
// between absent and not-ready is treated as the transition it is.
|
||||
if probe.is_absent() {
|
||||
absent_since.get_or_insert_with(Instant::now);
|
||||
} else {
|
||||
absent_since = None;
|
||||
}
|
||||
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
|
||||
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
|
||||
match reload_vdisplay_adapter() {
|
||||
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
|
||||
// burning the arrival window on a box that simply does not have it installed.
|
||||
AdapterCycle::NotInstalled => {
|
||||
let e = Err(probe.into_error()).context(
|
||||
"no punktfunk virtual-display adapter devnode exists — the driver is not \
|
||||
installed",
|
||||
);
|
||||
return (e, reloaded);
|
||||
}
|
||||
AdapterCycle::Refused(why) => {
|
||||
let e = Err(probe.into_error()).context(format!(
|
||||
"the pf-vdisplay adapter devnode could not be reloaded ({why})"
|
||||
));
|
||||
return (e, reloaded);
|
||||
}
|
||||
AdapterCycle::Reloaded { .. } => {
|
||||
reloaded = true;
|
||||
absent_since = None;
|
||||
deadline = (Instant::now() + ARRIVAL_AFTER_RELOAD).min(started + TOTAL_BUDGET);
|
||||
}
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let e = Err(probe.into_error()).context(format!(
|
||||
"the pf-vdisplay control interface did not appear within {:?}{}",
|
||||
started.elapsed(),
|
||||
if reloaded {
|
||||
" (including an adapter reload)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
return (e, reloaded);
|
||||
}
|
||||
std::thread::sleep(PROBE_INTERVAL);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -906,6 +1170,96 @@ mod tests {
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The recovery must not be able to claim success it did not achieve. This is the whole bug:
|
||||
/// the old script ran the cycle under `SilentlyContinue` and reported `(Get-PnpDevice).Status`,
|
||||
/// so a device whose disable had been REFUSED — untouched, still started — reported `OK`, and
|
||||
/// the host logged `cycled the adapter device … status=OK` while nothing had been cycled at all
|
||||
/// (field report 2026-08-02). A refusal must decode as a refusal, carrying its reason.
|
||||
#[test]
|
||||
fn a_refused_reload_is_not_reported_as_a_reload() {
|
||||
let refused =
|
||||
classify_reload_output("REFUSED This device cannot be disabled because it is in use.");
|
||||
match refused {
|
||||
AdapterCycle::Refused(why) => {
|
||||
assert!(why.contains("in use"), "the reason must survive: {why:?}")
|
||||
}
|
||||
other => panic!("a refused reload decoded as {}", variant(&other)),
|
||||
}
|
||||
// A bare device status — what the OLD script emitted on every path — must NEVER decode as a
|
||||
// successful reload now, however healthy it looks.
|
||||
for stale in ["OK", "Error", "Unknown"] {
|
||||
assert!(
|
||||
matches!(classify_reload_output(stale), AdapterCycle::Refused(_)),
|
||||
"{stale:?} is a device status, not a reload outcome"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
|
||||
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
|
||||
/// disable was refused and something still holds the device open).
|
||||
#[test]
|
||||
fn reload_outcomes_decode() {
|
||||
assert!(matches!(
|
||||
classify_reload_output("ABSENT"),
|
||||
AdapterCycle::NotInstalled
|
||||
));
|
||||
match classify_reload_output("RELOADED cycle OK") {
|
||||
AdapterCycle::Reloaded { how, status } => {
|
||||
assert_eq!(how, "disable+enable");
|
||||
assert_eq!(status, "OK");
|
||||
}
|
||||
other => panic!("expected Reloaded, got {}", variant(&other)),
|
||||
}
|
||||
match classify_reload_output("RELOADED restart OK\r\n") {
|
||||
AdapterCycle::Reloaded { how, status } => {
|
||||
assert_eq!(how, "pnputil /restart-device");
|
||||
assert_eq!(status, "OK");
|
||||
}
|
||||
other => panic!("expected Reloaded, got {}", variant(&other)),
|
||||
}
|
||||
// powershell died before writing anything — an un-reloaded devnode, so `Refused`, not a
|
||||
// silent success.
|
||||
assert!(matches!(
|
||||
classify_reload_output(" "),
|
||||
AdapterCycle::Refused(_)
|
||||
));
|
||||
}
|
||||
|
||||
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
|
||||
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
|
||||
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
|
||||
/// the outage it is already recovering from.
|
||||
#[test]
|
||||
fn only_a_total_absence_counts_as_absent() {
|
||||
let probe = |active, inactive| Probe {
|
||||
handle: None,
|
||||
active,
|
||||
inactive,
|
||||
last_err: None,
|
||||
};
|
||||
assert!(probe(0, 0).is_absent(), "no instances at all = absent");
|
||||
assert!(
|
||||
!probe(0, 1).is_absent(),
|
||||
"a registered-but-inactive instance is a device coming up, not a missing one"
|
||||
);
|
||||
assert!(
|
||||
!probe(1, 0).is_absent(),
|
||||
"an active instance we merely failed to open is not a missing device"
|
||||
);
|
||||
// And the diagnostic names what was seen — the old message collapsed every one of these
|
||||
// into "is the driver installed?", which sent a field report down the wrong path.
|
||||
assert!(probe(0, 2).into_error().to_string().contains("2 inactive"));
|
||||
}
|
||||
|
||||
fn variant(c: &AdapterCycle) -> &'static str {
|
||||
match c {
|
||||
AdapterCycle::Reloaded { .. } => "Reloaded",
|
||||
AdapterCycle::NotInstalled => "NotInstalled",
|
||||
AdapterCycle::Refused(_) => "Refused",
|
||||
}
|
||||
}
|
||||
|
||||
/// Live hardware round trip — `#[ignore]`d (needs the pf-vdisplay driver installed); run with
|
||||
/// `cargo test -p pf-vdisplay -- --ignored live_create_drop`. Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
|
||||
#[test]
|
||||
|
||||
@@ -4116,7 +4116,11 @@ pub struct PunktfunkProbeResult {
|
||||
/// Application goodput bytes / access units the host offered.
|
||||
pub host_bytes: u64,
|
||||
pub host_packets: u32,
|
||||
/// The host's measured burst duration, milliseconds (the throughput denominator).
|
||||
/// The throughput denominator, milliseconds: the client-measured burst receive interval
|
||||
/// (first → last probe-packet arrival) once `done`; the host's measured send-window
|
||||
/// duration when fewer than two probe packets arrived (no interval to measure from). The
|
||||
/// host duration alone overstates throughput — its window closes while the bottleneck
|
||||
/// queue is still draining toward the client.
|
||||
pub elapsed_ms: u32,
|
||||
/// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
|
||||
pub throughput_kbps: u32,
|
||||
@@ -4130,7 +4134,7 @@ pub struct PunktfunkProbeResult {
|
||||
}
|
||||
|
||||
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||
/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
||||
/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 10 Gbps / ≤ 5 s),
|
||||
/// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its
|
||||
/// `done` field is 1. Starting a probe resets any prior measurement.
|
||||
///
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
//! after ~4.5 s clean, ceilinged). Changes are rate-limited (each one costs the IDR the host's
|
||||
//! rebuilt encoder opens with) and the whole controller disables itself against a host that never
|
||||
//! answers [`crate::quic::BitrateChanged`] (an older build that ignores unknown control messages).
|
||||
//! Standing limits are LEARNED rather than re-poked: two identical short host acks latch the
|
||||
//! encoder's ceiling (`host_cap_kbps`), two consecutive decode-severe backoffs at a similar rate
|
||||
//! latch the client decoder's knee (`decode_cap_kbps`) — and both re-probe slowly
|
||||
//! ([`CAP_REPROBE_WINDOWS`]) so neither latch outlives the condition that taught it.
|
||||
//!
|
||||
//! Climbs are additionally **evidence-gated**. The target is only a *promise* to the encoder —
|
||||
//! how many bits it actually emits depends on the content — so on calm content (a menu, an idle
|
||||
@@ -129,7 +133,16 @@ const ENCODE_SEVERE_US: i64 = 12_000;
|
||||
/// evidence, not a spec limit — without a re-probe, one heavy scene would cap the whole
|
||||
/// session. A still-standing limit just re-teaches itself in two short acks, which the host
|
||||
/// pre-clamps without touching the encoder — the re-probe costs no rebuild, no IDR.
|
||||
/// The [`decode cap`](BitrateController::decode_cap_kbps) re-probes on the same clock for the
|
||||
/// same reason: the decoder's knee moves with content and thermals, so its latch must not be
|
||||
/// permanent either.
|
||||
const CAP_REPROBE_WINDOWS: u32 = 80;
|
||||
/// Two consecutive decode-driven backoffs latch the
|
||||
/// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree
|
||||
/// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its
|
||||
/// signature — two unrelated events (a Wi-Fi flush at 300 Mbps, a decode spike at 500) share
|
||||
/// no knee and must not teach one.
|
||||
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
@@ -137,6 +150,23 @@ const BASELINE_WINDOWS: usize = 40;
|
||||
/// predates bitrate renegotiation and going quiet for the rest of the session.
|
||||
const MAX_UNACKED: u32 = 3;
|
||||
|
||||
/// Operator escape hatch: `PUNKTFUNK_ABR_MAX_MBPS` (megabits/second, the
|
||||
/// `PUNKTFUNK_PYROWAVE_MAX_MBPS` convention) caps the climb ceiling however it is learned.
|
||||
/// The startup link-capacity probe MEASURES the ceiling, and
|
||||
/// [`set_ceiling`](BitrateController::set_ceiling)'s deliberate monotonicity makes an inflated
|
||||
/// measurement permanent for the session — a link that mis-measures (a bursty middlebox, a
|
||||
/// queue-flattered interval) needs a knob that binds regardless of what any probe claims.
|
||||
/// `PUNKTFUNK_ABR_PROBE_KBPS` is NOT that knob: it only shrinks the burst target, not what the
|
||||
/// measurement may conclude. Unset/0/garbage → no cap. Read once per controller, at
|
||||
/// construction.
|
||||
fn ceiling_cap_from_env() -> Option<u32> {
|
||||
std::env::var("PUNKTFUNK_ABR_MAX_MBPS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.filter(|&m| m > 0)
|
||||
.map(|m| m.saturating_mul(1_000))
|
||||
}
|
||||
|
||||
/// One decision per report window; `Some(kbps)` = send a [`crate::quic::SetBitrate`].
|
||||
pub(crate) struct BitrateController {
|
||||
/// `false` = permanently off (explicit user bitrate, an old host, or ack silence).
|
||||
@@ -147,6 +177,10 @@ pub(crate) struct BitrateController {
|
||||
/// raises it via [`set_ceiling`](Self::set_ceiling) — that measurement is what lets an
|
||||
/// Automatic session scale past its conservative start.
|
||||
ceiling_kbps: u32,
|
||||
/// The `PUNKTFUNK_ABR_MAX_MBPS` cap in kbps (see [`ceiling_cap_from_env`]), injected at
|
||||
/// construction so tests exercise the clamp without touching the process environment.
|
||||
/// `None` = no cap.
|
||||
ceiling_cap_kbps: Option<u32>,
|
||||
floor_kbps: u32,
|
||||
/// Slow start: true until the first congestion signal — clean windows DOUBLE the rate
|
||||
/// (cooldown-paced) instead of the +6 % additive step.
|
||||
@@ -178,6 +212,24 @@ pub(crate) struct BitrateController {
|
||||
short_acks: u32,
|
||||
/// Clean windows spent parked at the learned cap (the re-probe clock).
|
||||
cap_probe_windows: u32,
|
||||
/// The client-decoder rate cap, mirroring [`host_cap_kbps`](Self::host_cap_kbps) for the
|
||||
/// OTHER end of the pipe: latched when two CONSECUTIVE backoffs carried decode-severe
|
||||
/// evidence (a deep decode-latency excursion, or a jump-to-live flush — in the
|
||||
/// decoder-saturation regime the flushed backlog formed BEHIND a decoder that stopped
|
||||
/// keeping up) at a similar pre-backoff rate. Without it a decoder knee below the link
|
||||
/// ceiling is a permanent 30–60 s sawtooth: every ×0.7 backoff re-climbs toward a ceiling
|
||||
/// the decoder can't hold, and each cycle costs a flush plus a dropped-frame burst (the
|
||||
/// 1440p120 HEVC field case: knee ~490 Mbps under a ~658 Mbps ceiling). Slowly re-probed
|
||||
/// on the [`CAP_REPROBE_WINDOWS`] clock, exactly like the host cap, so a decoder that
|
||||
/// recovers (lighter content, thermal headroom) climbs again — the latch is never
|
||||
/// permanent.
|
||||
decode_cap_kbps: Option<u32>,
|
||||
/// The previous decode-driven backoff's pre-backoff rate (0 = the last backoff wasn't
|
||||
/// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`])
|
||||
/// to latch the cap — one spurious flush teaches nothing.
|
||||
decode_backoff_kbps: u32,
|
||||
/// Clean windows spent parked at the learned decode cap (its re-probe clock).
|
||||
decode_cap_probe_windows: u32,
|
||||
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
|
||||
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
|
||||
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
|
||||
@@ -196,10 +248,17 @@ impl BitrateController {
|
||||
/// to build a permanently-disabled controller (explicit bitrate / an old host that didn't
|
||||
/// echo one — no known ceiling to work against).
|
||||
pub(crate) fn new(start_kbps: u32) -> Self {
|
||||
Self::with_ceiling_cap(start_kbps, ceiling_cap_from_env())
|
||||
}
|
||||
|
||||
/// [`new`](Self::new) with the `PUNKTFUNK_ABR_MAX_MBPS` cap injected — the seam the unit
|
||||
/// tests use so the clamp's behavior never depends on the test process's environment.
|
||||
fn with_ceiling_cap(start_kbps: u32, ceiling_cap_kbps: Option<u32>) -> Self {
|
||||
BitrateController {
|
||||
enabled: start_kbps > 0,
|
||||
current_kbps: start_kbps,
|
||||
ceiling_kbps: start_kbps,
|
||||
ceiling_cap_kbps,
|
||||
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
||||
probing: true,
|
||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
@@ -210,6 +269,9 @@ impl BitrateController {
|
||||
short_ack_kbps: 0,
|
||||
short_acks: 0,
|
||||
cap_probe_windows: 0,
|
||||
decode_cap_kbps: None,
|
||||
decode_backoff_kbps: 0,
|
||||
decode_cap_probe_windows: 0,
|
||||
proven_kbps: 0,
|
||||
bad_windows: 0,
|
||||
clean_windows: 0,
|
||||
@@ -222,8 +284,12 @@ impl BitrateController {
|
||||
/// delivered throughput with headroom already subtracted by the caller). Without this call
|
||||
/// the ceiling stays the negotiated start rate — exactly the old behavior. Never lowers:
|
||||
/// a congested-moment measurement must not shrink authority below what was negotiated
|
||||
/// (descent is the congestion signals' job).
|
||||
/// (descent is the congestion signals' job). The `PUNKTFUNK_ABR_MAX_MBPS` cap clamps HERE
|
||||
/// — the one funnel every learned ceiling passes through — so it binds no matter how the
|
||||
/// ceiling was learned; monotonicity is precisely why the user needs it (one inflated
|
||||
/// measurement is otherwise permanent for the session).
|
||||
pub(crate) fn set_ceiling(&mut self, kbps: u32) {
|
||||
let kbps = kbps.min(self.ceiling_cap_kbps.unwrap_or(u32::MAX));
|
||||
if self.enabled && kbps > self.ceiling_kbps {
|
||||
self.ceiling_kbps = kbps;
|
||||
}
|
||||
@@ -274,11 +340,16 @@ impl BitrateController {
|
||||
|
||||
/// An accepted mode switch: the encoder's ceiling and compute knee are properties of the
|
||||
/// MODE (4K120 caps where 1080p60 never would) — drop the mode-scoped learned state. The
|
||||
/// probe-measured `ceiling_kbps` (a LINK property) survives.
|
||||
/// decoder's knee is just as mode-scoped (pixel rate drives both ends of the codec), so
|
||||
/// the decode cap goes with it. The probe-measured `ceiling_kbps` (a LINK property)
|
||||
/// survives.
|
||||
pub(crate) fn on_mode_switch(&mut self) {
|
||||
self.host_cap_kbps = None;
|
||||
self.short_acks = 0;
|
||||
self.cap_probe_windows = 0;
|
||||
self.decode_cap_kbps = None;
|
||||
self.decode_backoff_kbps = 0;
|
||||
self.decode_cap_probe_windows = 0;
|
||||
self.encode_means.clear();
|
||||
}
|
||||
|
||||
@@ -427,6 +498,30 @@ impl BitrateController {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The decode cap re-probes on the same clock and for the same reason: the knee is
|
||||
// content- and thermals-dependent evidence, not a spec limit — a decoder that recovers
|
||||
// must get its headroom back, so the latch clears UPWARD through here rather than ever
|
||||
// being permanent. A still-standing knee re-latches from the next pair of
|
||||
// decode-driven backoffs.
|
||||
if let Some(cap) = self.decode_cap_kbps {
|
||||
if bad {
|
||||
self.decode_cap_probe_windows = 0;
|
||||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||||
self.decode_cap_probe_windows += 1;
|
||||
if self.decode_cap_probe_windows >= CAP_REPROBE_WINDOWS {
|
||||
self.decode_cap_probe_windows = 0;
|
||||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||||
if lifted > cap {
|
||||
tracing::debug!(
|
||||
from_kbps = cap,
|
||||
to_kbps = lifted,
|
||||
"adaptive bitrate: re-probing above the learned decode cap"
|
||||
);
|
||||
self.decode_cap_kbps = Some(lifted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let cooled = self
|
||||
.last_change
|
||||
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
|
||||
@@ -436,6 +531,31 @@ impl BitrateController {
|
||||
if (self.bad_windows >= BAD_WINDOWS_TO_DECREASE || (severe && self.bad_windows >= 1))
|
||||
&& self.current_kbps > self.floor_kbps
|
||||
{
|
||||
// Decode-cap learning (see [`decode_cap_kbps`](Self::decode_cap_kbps)): a backoff
|
||||
// with decode-severe evidence — the deep decode excursion, or the flush that
|
||||
// drained the queue behind a stalled decoder — remembers its pre-backoff rate; the
|
||||
// SECOND consecutive one at a similar rate latches that rate as the decoder's
|
||||
// knee. One event never latches (a spurious flush must stay a one-off), and a
|
||||
// backoff without decode evidence in between breaks the streak — whatever it saw,
|
||||
// it wasn't the same knee.
|
||||
if decode_severe || flushed {
|
||||
let rate = self.current_kbps;
|
||||
let similar = self.decode_backoff_kbps > 0
|
||||
&& rate.abs_diff(self.decode_backoff_kbps)
|
||||
<= self.decode_backoff_kbps / DECODE_CAP_SIMILAR_DIV;
|
||||
if similar && self.decode_cap_kbps.is_none_or(|c| rate < c) {
|
||||
tracing::info!(
|
||||
cap_kbps = rate,
|
||||
"adaptive bitrate: decode cap learned (decoder knee) — climbs stop \
|
||||
here until it lifts"
|
||||
);
|
||||
self.decode_cap_kbps = Some(rate.max(self.floor_kbps));
|
||||
self.decode_cap_probe_windows = 0;
|
||||
}
|
||||
self.decode_backoff_kbps = rate;
|
||||
} else {
|
||||
self.decode_backoff_kbps = 0;
|
||||
}
|
||||
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
||||
self.bad_windows = 0;
|
||||
return self.request(next, now);
|
||||
@@ -447,11 +567,13 @@ impl BitrateController {
|
||||
// utilized window after a long-enough clean run climbs immediately.
|
||||
let utilized =
|
||||
actual_kbps as u64 * UTILIZATION_DEN >= self.current_kbps as u64 * UTILIZATION_NUM;
|
||||
// The effective ceiling folds in the host-taught cap: the probe measured the LINK, but
|
||||
// the host's short acks measured the ENCODER — whichever binds first is the limit.
|
||||
// The effective ceiling folds in both learned caps: the probe measured the LINK, the
|
||||
// host's short acks measured the ENCODER, and the decode cap measured the CLIENT
|
||||
// DECODER — whichever binds first is the limit.
|
||||
let eff_ceiling = self
|
||||
.ceiling_kbps
|
||||
.min(self.host_cap_kbps.unwrap_or(u32::MAX));
|
||||
.min(self.host_cap_kbps.unwrap_or(u32::MAX))
|
||||
.min(self.decode_cap_kbps.unwrap_or(u32::MAX));
|
||||
let cap = eff_ceiling
|
||||
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
|
||||
if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps {
|
||||
@@ -1447,6 +1569,243 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_max_mbps_caps_every_learned_ceiling() {
|
||||
// PUNKTFUNK_ABR_MAX_MBPS=50 (injected — `new` reads the env exactly once, at
|
||||
// construction): a probe "measuring" 886 Mbps (the divisor bug's field figure) must
|
||||
// not out-rank the user's cap…
|
||||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||||
c.set_ceiling(886_312);
|
||||
assert_eq!(c.ceiling_kbps, 50_000);
|
||||
// …while a measurement under the cap stands untouched.
|
||||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||||
c.set_ceiling(40_000);
|
||||
assert_eq!(c.ceiling_kbps, 40_000);
|
||||
// And the climb honors it: slow start doubles 20→40, the capped ceiling truncates the
|
||||
// next step to 50, then quiet — never a request past the user's limit.
|
||||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||||
c.set_ceiling(886_312);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(40_000));
|
||||
c.on_ack(40_000);
|
||||
assert_eq!(run_clean(&mut c, start, 2, 1), Some(50_000));
|
||||
c.on_ack(50_000);
|
||||
assert_eq!(run_clean(&mut c, start, 4, 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_latches_after_two_consecutive_decode_severe_backoffs() {
|
||||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||||
// link ceiling — nothing ever LEARNED the knee, so every re-climb ended in a flush +
|
||||
// dropped-frame burst. Establish a decode baseline on calm windows, choke twice at the
|
||||
// same rate, and the second decode-severe backoff must latch the knee.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
// Calm baseline windows (2 Mb/s actual: unutilized, so no climb interferes).
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
// First deep decode excursion → immediate ×0.7, but ONE event must not latch.
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(350_000)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
// Second consecutive decode-severe backoff at the same pre-backoff rate: latch.
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 6),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0
|
||||
),
|
||||
Some(350_000)
|
||||
);
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
// The backoff applies; from here every climb must stop AT the knee — not the 900 Mbps
|
||||
// link ceiling the old sawtooth kept re-poking.
|
||||
c.on_ack(350_000);
|
||||
let mut max_req = 0;
|
||||
for i in 8..70 {
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
0,
|
||||
) {
|
||||
assert!(k <= 500_000, "climb past the decode cap: {k}");
|
||||
max_req = max_req.max(k);
|
||||
c.on_ack(k);
|
||||
}
|
||||
}
|
||||
assert_eq!(max_req, 500_000);
|
||||
assert_eq!(c.current_kbps, 500_000);
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_flush_or_dissimilar_backoffs_never_latch_a_decode_cap() {
|
||||
// The latch's false-positive guards. A lone jump-to-live flush (a Wi-Fi clump can
|
||||
// flush once at ANY rate) backs off but teaches nothing…
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, None, None, None, 490_000, true, 0),
|
||||
Some(350_000)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(350_000);
|
||||
// …a LOSS-driven backoff in between breaks the streak…
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, None, 340_000, false, 0),
|
||||
Some(245_000)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(245_000);
|
||||
// …so the next flush counts as a FIRST decode event again — still no latch…
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 4), 0, 0, None, None, None, 240_000, true, 0),
|
||||
Some(171_500)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
c.on_ack(171_500);
|
||||
// …and two consecutive decode events at DISSIMILAR rates (245 vs 171.5 Mbps — no
|
||||
// common knee) must not latch either.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 6), 0, 0, None, None, None, 170_000, true, 0),
|
||||
Some(120_050)
|
||||
);
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_cap_reprobes_after_a_sustained_clean_run() {
|
||||
// The knee is content/thermals evidence, not a spec limit: after ~60 s parked clean at
|
||||
// the latched cap, it lifts one step (+12.5 %, ceiling-bounded) — the re-probe path is
|
||||
// how the latch clears (never permanent), and a still-standing knee just re-latches
|
||||
// from the next pair of decode-driven backoffs.
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
for i in [4, 6] {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
// The host's ack parks the session at the knee (its clamp is authoritative).
|
||||
c.on_ack(500_000);
|
||||
for i in 0..CAP_REPROBE_WINDOWS {
|
||||
let _ = c.on_window(
|
||||
ticks(start, 8 + i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000 + 500_000 / 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_switch_clears_the_decode_cap() {
|
||||
// A 1440p120 knee means nothing at the new mode's pixel rate — the decode cap must
|
||||
// not survive the switch (the probe-measured link ceiling does).
|
||||
let mut c = BitrateController::new(500_000);
|
||||
c.set_ceiling(900_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
for i in [4, 6] {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
490_000,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.decode_cap_kbps, Some(500_000));
|
||||
c.on_mode_switch();
|
||||
assert!(c.decode_cap_kbps.is_none());
|
||||
assert_eq!(c.ceiling_kbps, 900_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_silence_disables_the_controller() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
|
||||
@@ -883,7 +883,7 @@ impl NativeClient {
|
||||
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
|
||||
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
|
||||
/// `done` flag is set. Starting a probe resets any prior measurement. The host clamps both
|
||||
/// fields (≤ 3 Gbps, ≤ 5 s).
|
||||
/// fields (≤ 10 Gbps, ≤ 5 s).
|
||||
pub fn request_probe(&self, target_kbps: u32, duration_ms: u32) -> Result<()> {
|
||||
// Reset the accumulator so a fresh run doesn't blend into the previous one.
|
||||
*self.probe.lock().unwrap() = ProbeState {
|
||||
@@ -922,8 +922,12 @@ impl NativeClient {
|
||||
p.rx_bytes_now.saturating_sub(base_b),
|
||||
)
|
||||
};
|
||||
// The host's burst duration is the throughput denominator. bytes × 8 / ms = kilobits/second.
|
||||
let window_ms = p.host_duration_ms;
|
||||
// The throughput denominator: the client-measured receive interval once the report
|
||||
// froze one, the host's send-window duration as the fallback (see
|
||||
// `ProbeState::measured_interval_ms` for why the host window alone overstates the
|
||||
// link). Both are 0 until the report lands, so a partial read reports 0 throughput —
|
||||
// unchanged. bytes × 8 / ms = kilobits/second.
|
||||
let window_ms = p.throughput_window_ms();
|
||||
let throughput_kbps = if window_ms > 0 {
|
||||
(delivered_bytes.saturating_mul(8) / window_ms as u64) as u32
|
||||
} else {
|
||||
|
||||
@@ -1,34 +1,50 @@
|
||||
//! Speed-test probe state (`ProbeState`, pump-mirrored) and the public `ProbeOutcome`.
|
||||
|
||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
||||
/// session's packet-level receive counters here; the control task finalizes the delivered figure
|
||||
/// session's probe-scoped receive counters here; the control task finalizes the delivered figure
|
||||
/// and folds in the host's [`ProbeResult`] when it lands. Read by [`NativeClient::probe_result`].
|
||||
///
|
||||
/// Counting at the *packet* level (every delivered wire packet) — not whole reassembled probe AUs —
|
||||
/// is what makes the measurement degrade gracefully: once loss exceeds the FEC budget no AU
|
||||
/// completes, so the old AU-based count cliffed to zero even though most bytes still arrived.
|
||||
/// Counting *probe* packets only (the reassembler stamps dedicated counters at its FLAG_PROBE
|
||||
/// routing) keeps video out of the numerator: the burst pauses video, but frames already in
|
||||
/// flight land during its head, and resumed video lands between the last probe packet and the
|
||||
/// host's report — both used to inflate the all-datagram byte delta this mirrored before.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ProbeState {
|
||||
/// A probe is in progress: set by `request_probe`, cleared when the host's [`ProbeResult`]
|
||||
/// lands (a re-probe just overwrites the whole state — the latest one wins).
|
||||
pub(crate) active: bool,
|
||||
/// `session.stats()` receive counters at the burst's start (snapshotted by the pump on its first
|
||||
/// tick while active) and latest, mirrored every pump iteration.
|
||||
/// Probe-scoped receive counters (`Stats::probe_*`) at the burst's start (snapshotted by the
|
||||
/// pump on its first tick while active) and latest, mirrored every pump iteration.
|
||||
pub(crate) base_packets: Option<u64>,
|
||||
pub(crate) base_bytes: Option<u64>,
|
||||
pub(crate) rx_packets_now: u64,
|
||||
pub(crate) rx_bytes_now: u64,
|
||||
/// First / last probe-packet arrival stamps (monotonic ns, 0 = none yet), mirrored from the
|
||||
/// probe-scoped session counters. Their difference is the interval the delivered bytes
|
||||
/// actually arrived in — the honest throughput denominator (see
|
||||
/// [`measured_interval_ms`](Self::measured_interval_ms)).
|
||||
pub(crate) first_arrival_ns: u64,
|
||||
pub(crate) last_arrival_ns: u64,
|
||||
/// Delivered wire packets / plaintext bytes (header + shard), frozen when the host's report lands
|
||||
/// (so resumed video after the burst can't inflate them).
|
||||
pub(crate) delivered_packets: u64,
|
||||
pub(crate) delivered_bytes: u64,
|
||||
/// The client-measured receive interval (ms), frozen alongside the delivered figures; 0 = no
|
||||
/// usable interval (the burst delivered fewer than two probe packets) — consumers fall back
|
||||
/// to [`host_duration_ms`](Self::host_duration_ms) via
|
||||
/// [`throughput_window_ms`](Self::throughput_window_ms).
|
||||
pub(crate) client_interval_ms: u32,
|
||||
/// The host's end-of-burst report.
|
||||
pub(crate) host_goodput_bytes: u64,
|
||||
pub(crate) host_au: u32,
|
||||
/// Wire packets the host actually put on the link, and the ones its send buffer dropped.
|
||||
pub(crate) host_wire_packets: u32,
|
||||
pub(crate) host_send_dropped: u32,
|
||||
/// The host's measured burst duration (the throughput denominator).
|
||||
/// The host's measured burst duration (the throughput denominator's FALLBACK — see
|
||||
/// [`throughput_window_ms`](Self::throughput_window_ms)).
|
||||
pub(crate) host_duration_ms: u32,
|
||||
/// The host's `ProbeResult` arrived → the measurement is final.
|
||||
pub(crate) done: bool,
|
||||
@@ -39,6 +55,40 @@ pub(crate) struct ProbeState {
|
||||
pub(crate) duration_ms: u32,
|
||||
}
|
||||
|
||||
impl ProbeState {
|
||||
/// The client-measured receive interval of a finished burst, in ms: first → last
|
||||
/// probe-packet arrival, floored at 1 (a sub-ms burst divided by 0 ms would read as
|
||||
/// infinite throughput). `None` — the caller falls back to the host's duration — when
|
||||
/// fewer than two probe packets arrived or the stamps are degenerate (unset / identical /
|
||||
/// reversed): a single arrival spans no interval.
|
||||
///
|
||||
/// Why not the host's `duration_ms`: it measures the SEND window, which closes while the
|
||||
/// bottleneck (switch/kernel) queue is still draining toward the client — the tail of the
|
||||
/// bytes lands *after* it. Dividing client-side bytes by the host-side window therefore
|
||||
/// overstates the link: a 1 GbE link under a 2 Gbps burst target "measured" 1266 Mbps and
|
||||
/// handed the ABR an 886 Mbps ceiling it could never deliver — and
|
||||
/// [`set_ceiling`](crate::abr::BitrateController::set_ceiling) never lowers, so the lie
|
||||
/// was permanent for the session.
|
||||
pub(crate) fn measured_interval_ms(first_ns: u64, last_ns: u64, packets: u64) -> Option<u32> {
|
||||
if packets < 2 || first_ns == 0 || last_ns <= first_ns {
|
||||
return None;
|
||||
}
|
||||
let ms = ((last_ns - first_ns) / 1_000_000).max(1);
|
||||
Some(u32::try_from(ms).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
/// The throughput denominator, in ms: the client-measured receive interval when the burst
|
||||
/// produced one, else the host's send-window duration (an old measurement is better than
|
||||
/// none — and strictly conservative territory only when packets were too few to matter).
|
||||
pub(crate) fn throughput_window_ms(&self) -> u32 {
|
||||
if self.client_interval_ms > 0 {
|
||||
self.client_interval_ms
|
||||
} else {
|
||||
self.host_duration_ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished/partial speed-test measurement, returned by [`NativeClient::probe_result`].
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ProbeOutcome {
|
||||
@@ -50,7 +100,11 @@ pub struct ProbeOutcome {
|
||||
/// Application goodput bytes / access units the host offered.
|
||||
pub host_bytes: u64,
|
||||
pub host_packets: u32,
|
||||
/// The burst duration the host measured, in milliseconds (the throughput denominator).
|
||||
/// The throughput denominator, in milliseconds: the client-measured receive interval
|
||||
/// (first → last probe-packet arrival) once `done`; the host's measured send-window
|
||||
/// duration when the burst delivered fewer than two probe packets (no interval to measure
|
||||
/// from). The host duration alone overstates throughput — its window closes while the
|
||||
/// bottleneck queue is still draining toward the client.
|
||||
pub elapsed_ms: u32,
|
||||
/// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second). The figure to
|
||||
/// drive a [`Hello::bitrate_kbps`] choice from (allow headroom for the FEC overhead + loss).
|
||||
@@ -66,3 +120,63 @@ pub struct ProbeOutcome {
|
||||
pub wire_packets_sent: u32,
|
||||
pub send_dropped: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interval_needs_two_packets_and_a_nonzero_span() {
|
||||
// <2 packets: no interval exists — the caller must fall back to the host duration.
|
||||
assert_eq!(ProbeState::measured_interval_ms(0, 0, 0), None);
|
||||
assert_eq!(
|
||||
ProbeState::measured_interval_ms(5_000_000, 5_000_000, 1),
|
||||
None
|
||||
);
|
||||
// Two packets in the same ns / a reversed pair / an unset first stamp: same fallback.
|
||||
assert_eq!(
|
||||
ProbeState::measured_interval_ms(5_000_000, 5_000_000, 2),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ProbeState::measured_interval_ms(9_000_000, 5_000_000, 2),
|
||||
None
|
||||
);
|
||||
assert_eq!(ProbeState::measured_interval_ms(0, 5_000_000, 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_is_floored_at_one_ms() {
|
||||
// Two packets 0.4 ms apart truncate to 0 ms — the floor keeps the division honest
|
||||
// instead of infinite.
|
||||
assert_eq!(ProbeState::measured_interval_ms(1_000, 401_000, 2), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_measures_first_to_last_arrival() {
|
||||
assert_eq!(
|
||||
ProbeState::measured_interval_ms(1_000_000, 801_000_000, 1_000),
|
||||
Some(800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throughput_window_falls_back_to_the_host_duration() {
|
||||
// No client interval frozen (a <2-packet burst) → the host's send window is the
|
||||
// denominator, exactly the old behavior.
|
||||
let p = ProbeState {
|
||||
host_duration_ms: 800,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(p.throughput_window_ms(), 800);
|
||||
// With an interval, the client measurement wins — the 1 GbE field case: the same
|
||||
// bytes over 1010 ms instead of the host's 800 ms is the difference between an
|
||||
// honest ~940 Mbps and an impossible 1266 Mbps.
|
||||
let p = ProbeState {
|
||||
client_interval_ms: 1_010,
|
||||
host_duration_ms: 800,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(p.throughput_window_ms(), 1_010);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +132,24 @@ impl ControlTask {
|
||||
}
|
||||
} else if let Ok(result) = ProbeResult::decode(&msg) {
|
||||
let mut p = probe.lock().unwrap();
|
||||
// Freeze the delivered figures now (the burst is done), before resumed
|
||||
// video can inflate the packet counters.
|
||||
// Freeze the delivered figures now (the burst is done). The mirrored
|
||||
// counters are probe-scoped (stamped at the reassembler's FLAG_PROBE
|
||||
// routing), so video around the burst inflates nothing; the client's
|
||||
// first→last arrival interval is frozen with them — the denominator
|
||||
// that measures when the bytes actually ARRIVED, not when the host
|
||||
// stopped sending (its window closes while the bottleneck queue is
|
||||
// still draining this way, which is how a 1 GbE link once "measured"
|
||||
// 1266 Mbps).
|
||||
let base_p = p.base_packets.unwrap_or(p.rx_packets_now);
|
||||
let base_b = p.base_bytes.unwrap_or(p.rx_bytes_now);
|
||||
p.delivered_packets = p.rx_packets_now.saturating_sub(base_p);
|
||||
p.delivered_bytes = p.rx_bytes_now.saturating_sub(base_b);
|
||||
p.client_interval_ms = ProbeState::measured_interval_ms(
|
||||
p.first_arrival_ns,
|
||||
p.last_arrival_ns,
|
||||
p.delivered_packets,
|
||||
)
|
||||
.unwrap_or(0);
|
||||
p.host_goodput_bytes = result.bytes_sent;
|
||||
p.host_au = result.packets_sent;
|
||||
p.host_wire_packets = result.wire_packets_sent;
|
||||
@@ -151,6 +163,7 @@ impl ControlTask {
|
||||
send_dropped = result.send_dropped,
|
||||
duration_ms = result.duration_ms,
|
||||
delivered_packets = p.delivered_packets,
|
||||
client_interval_ms = p.client_interval_ms,
|
||||
"speed-test probe result"
|
||||
);
|
||||
} else if let Ok(ack) = BitrateChanged::decode(&msg) {
|
||||
|
||||
@@ -200,10 +200,23 @@ impl DataPump {
|
||||
let probe_active = {
|
||||
let mut p = pump_probe.lock().unwrap();
|
||||
if p.active && !p.done {
|
||||
p.rx_packets_now = st.packets_received;
|
||||
p.rx_bytes_now = st.bytes_received;
|
||||
p.base_packets.get_or_insert(st.packets_received);
|
||||
p.base_bytes.get_or_insert(st.bytes_received);
|
||||
// Arm edge (first mirror tick): zero the arrival stamps before the burst can
|
||||
// claim them — the ProbeRequest is still queued locally (the burst starts a
|
||||
// round trip later), so the reset cannot race a probe packet. `st` predates
|
||||
// the reset, so the stamps mirror 0 on this tick and live values after.
|
||||
let arming = p.base_bytes.is_none();
|
||||
if arming {
|
||||
session.reset_probe_arrivals();
|
||||
}
|
||||
p.rx_packets_now = st.probe_packets_received;
|
||||
p.rx_bytes_now = st.probe_bytes_received;
|
||||
(p.first_arrival_ns, p.last_arrival_ns) = if arming {
|
||||
(0, 0)
|
||||
} else {
|
||||
(st.probe_first_arrival_ns, st.probe_last_arrival_ns)
|
||||
};
|
||||
p.base_packets.get_or_insert(st.probe_packets_received);
|
||||
p.base_bytes.get_or_insert(st.probe_bytes_received);
|
||||
}
|
||||
p.active && !p.done
|
||||
};
|
||||
@@ -280,16 +293,23 @@ impl DataPump {
|
||||
if p.done {
|
||||
capacity_probe_deadline = None;
|
||||
// An all-zero reply is a decline (old host / probe-less build) — keep the
|
||||
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7.
|
||||
// negotiated ceiling. Otherwise: delivered wire kbps × 0.7, over the
|
||||
// CLIENT-measured receive interval (the host's send window closes while the
|
||||
// bottleneck queue is still draining toward us, so dividing by ITS duration
|
||||
// overstates the link — a 1 GbE link "measured" 1266 Mbps, and the inflated
|
||||
// ceiling is permanent because set_ceiling never lowers); the host duration
|
||||
// is the fallback when the burst delivered too few packets for an interval.
|
||||
if p.host_duration_ms > 0 && p.delivered_bytes > 0 {
|
||||
let delivered_kbps = (p.delivered_bytes.saturating_mul(8)
|
||||
/ p.host_duration_ms.max(1) as u64)
|
||||
as u32;
|
||||
let window_ms = p.throughput_window_ms();
|
||||
let delivered_kbps =
|
||||
(p.delivered_bytes.saturating_mul(8) / window_ms.max(1) as u64) as u32;
|
||||
let ceiling = delivered_kbps.saturating_mul(7) / 10;
|
||||
abr.set_ceiling(ceiling);
|
||||
tracing::info!(
|
||||
delivered_kbps,
|
||||
ceiling_kbps = ceiling,
|
||||
client_interval_ms = p.client_interval_ms,
|
||||
host_duration_ms = p.host_duration_ms,
|
||||
"adaptive bitrate: link-capacity probe done — climb ceiling set"
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -73,9 +73,9 @@ pub struct StreamedAu {
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
/// Bytes not yet sealed into a block: the sub-shard remainder plus anything below the
|
||||
/// slice-flush threshold. The final block always has ≥ 1 byte (flushes emit only whole
|
||||
/// shards and never drain to empty on a slice that ends the AU — `finish_streamed` seals
|
||||
/// whatever remains).
|
||||
/// slice-flush threshold. The final block always has ≥ 1 byte — flushes emit only whole
|
||||
/// shards, and a flush that WOULD empty this keeps one shard back (see `push_streamed`),
|
||||
/// so `finish_streamed` always has something real to seal.
|
||||
pending: Vec<u8>,
|
||||
/// Sentinel blocks already emitted.
|
||||
blocks_out: u16,
|
||||
@@ -418,7 +418,18 @@ impl Packetizer {
|
||||
"streamed AU exceeds the negotiated max_frame_bytes",
|
||||
));
|
||||
}
|
||||
let k = whole.min(self.fec.max_data_per_block as usize);
|
||||
// Never drain `pending` to EMPTY. [`finish_streamed`] must have bytes left to seal,
|
||||
// or the final block degenerates to a single zero-padded filler shard whose derived
|
||||
// base (`total_data − 1`) overlaps the block flushed just now — which the receiver's
|
||||
// retro-validation correctly reads as a lying header and kills the whole AU. It bites
|
||||
// exactly when the AU's length is a multiple of `shard_payload` (~1 in 1408 frames on
|
||||
// a 1500-MTU link), and only on the slice arm: the legacy `must_flush` is a strict
|
||||
// `>`, so its remainder is never empty. Keeping one whole shard back costs nothing —
|
||||
// it rides out in the final block, which has to exist regardless.
|
||||
let mut k = whole.min(self.fec.max_data_per_block as usize);
|
||||
if k > 1 && k == whole && au.pending.len() == whole * payload {
|
||||
k -= 1;
|
||||
}
|
||||
let sof = !au.opened;
|
||||
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
|
||||
let fi = au.frame_index;
|
||||
|
||||
@@ -409,6 +409,27 @@ impl Reassembler {
|
||||
// can neither advance the video anchor nor be dropped as stale against it (and its aged-out
|
||||
// frames never count as `frames_dropped`, which would fire video loss recovery).
|
||||
let is_probe = hdr.user_flags & (FLAG_PROBE as u32) != 0;
|
||||
if is_probe {
|
||||
// Probe-scoped receive accounting (the speed-test numerator + denominator, see
|
||||
// `Stats::probe_first_arrival_ns`), stamped at the routing decision so video in
|
||||
// flight around the burst contaminates neither the byte count nor the arrival
|
||||
// stamps. Byte unit mirrors `bytes_received` (whole plaintext packet). The first
|
||||
// probe packet since the pump armed the probe claims the first-arrival slot (the
|
||||
// pump zeroes it before the burst can reach the host); every probe packet
|
||||
// refreshes the last-arrival stamp.
|
||||
let now_ns = crate::stats::now_monotonic_ns();
|
||||
StatsCounters::add(&stats.probe_packets_received, 1);
|
||||
StatsCounters::add(&stats.probe_bytes_received, pkt.len() as u64);
|
||||
let _ = stats.probe_first_arrival_ns.compare_exchange(
|
||||
0,
|
||||
now_ns,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
stats
|
||||
.probe_last_arrival_ns
|
||||
.store(now_ns, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
let win = if is_probe { probe } else { video };
|
||||
win.advance_window(
|
||||
hdr.frame_index,
|
||||
@@ -446,14 +467,33 @@ impl Reassembler {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
|
||||
// packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at
|
||||
// the limits' maximum — its real size doesn't exist yet.
|
||||
let buf_len = if sentinel {
|
||||
total_data_max * shard_bytes
|
||||
// How many shards of frame buffer THIS packet proves the frame needs. A sentinel carries
|
||||
// no total, but it does pin its own block's extent — a slice sentinel by its wire base,
|
||||
// a legacy one by its full-K position — and that is what the buffer must cover to place
|
||||
// the shard. The frame grows as later blocks reveal more, and the final (non-sentinel)
|
||||
// block's totals settle it.
|
||||
//
|
||||
// ⚠ NOT `total_data_max` (= the negotiated `max_frame_bytes`, 8-64 MiB): that shape
|
||||
// shipped in 0.23.0 and was survivable only while sentinels were rare — the streamed
|
||||
// path emitted one solely for an AU exceeding a whole FEC block (~281 KB). The slice
|
||||
// wire flushes at `MIN_STREAM_BLOCK_SHARDS`, so EVERY ordinary AU became sentinel-opened
|
||||
// and every one of them committed the full ceiling: a multi-megabyte zeroed allocation
|
||||
// per access unit, and an in-flight budget (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`)
|
||||
// exhausted after ~3 concurrent frames — beyond which every packet of every further
|
||||
// frame was dropped outright. On a jittery link that is a permanent loss storm.
|
||||
let need_shards = if sentinel && slice_stream {
|
||||
frame_bytes / shard_bytes + data_shards
|
||||
} else if sentinel {
|
||||
// Legacy sentinels are full-K uniform blocks (firewall-enforced), so the block's
|
||||
// index alone gives its end.
|
||||
(block_idx + 1).saturating_mul(lim.max_data_shards)
|
||||
} else {
|
||||
total_data * shard_bytes
|
||||
};
|
||||
total_data
|
||||
}
|
||||
.min(total_data_max);
|
||||
// First packet of a frame allocates its (zeroed) buffer, budget-gated; later packets must
|
||||
// agree with its geometry.
|
||||
let buf_len = need_shards * shard_bytes;
|
||||
let frame = match win.frames.entry(hdr.frame_index) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
@@ -581,6 +621,21 @@ impl Reassembler {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
// Grow to this packet's proven extent. A streamed frame opens at whichever block arrived
|
||||
// first and learns its real size from the final block's totals (or a later, higher
|
||||
// sentinel base) — reorder means either can come first, so the buffer is sized by
|
||||
// whatever the frame has proven so far. Never shrinks: the totals only settle the frame's
|
||||
// END, and completion truncates to `frame_bytes` anyway. The budget is re-checked here
|
||||
// for exactly the reason it is checked at open — growth commits memory too.
|
||||
if buf_len > frame.buf.len() {
|
||||
let delta = buf_len - frame.buf.len();
|
||||
if *in_flight_bytes + delta > IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes {
|
||||
drop(stats);
|
||||
return Ok(None);
|
||||
}
|
||||
*in_flight_bytes += delta;
|
||||
frame.buf.resize(buf_len, 0);
|
||||
}
|
||||
let FrameBuf {
|
||||
buf,
|
||||
blocks,
|
||||
|
||||
@@ -941,8 +941,9 @@ fn slice_config() -> Config {
|
||||
|
||||
/// Slice chunks chosen to exercise every packetizer path: an exact-shard slice, a slice with
|
||||
/// a sub-shard remainder, a slice below [`MIN_STREAM_BLOCK_SHARDS`] that must accumulate,
|
||||
/// and a finish tail. 1023 B total → blocks (K, base-shard): (20, 0), (25, 20), (18, 45),
|
||||
/// final (1, 63) with block_count 4.
|
||||
/// and a finish tail. 1023 B total → blocks (K, base-shard): (19, 0), (26, 19), (18, 45),
|
||||
/// final (1, 63) with block_count 4. Chunk 0 is an exact 20-shard multiple and flushes 19:
|
||||
/// a flush never drains `pending` to empty, so `finish_streamed` always seals real bytes.
|
||||
fn slice_chunks() -> Vec<Vec<u8>> {
|
||||
[320usize, 403, 100, 200]
|
||||
.iter()
|
||||
@@ -1007,7 +1008,8 @@ fn slice_streamed_wire_shape_and_roundtrip() {
|
||||
assert_eq!(src.len(), 1023);
|
||||
// (block_index, K, base bytes) — chunk 2 (100 B) accumulated instead of flushing (6
|
||||
// whole shards < MIN_STREAM_BLOCK_SHARDS) and rode into block 2 with chunk 3's bytes.
|
||||
let expect = [(0u16, 20u16, 0u32), (1, 25, 320), (2, 18, 720)];
|
||||
// Block 0 keeps one shard back (chunk 0 is an exact multiple), which rides into block 1.
|
||||
let expect = [(0u16, 19u16, 0u32), (1, 26, 304), (2, 18, 720)];
|
||||
for p in &pkts {
|
||||
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
|
||||
assert_ne!(
|
||||
@@ -1478,15 +1480,24 @@ fn parts_flow_for_legacy_streamed_frames() {
|
||||
assert!(got.last().unwrap().complete);
|
||||
}
|
||||
|
||||
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
|
||||
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
|
||||
/// A one-datagram open commits only the buffer its OWN header proves it needs, and the
|
||||
/// in-flight budget still bounds the ones that claim a lot.
|
||||
///
|
||||
/// Both halves matter. A sentinel that claims little must cost little: sizing every
|
||||
/// sentinel-opened frame at `max_frame_bytes` (the 0.23.0 shape) was survivable only while
|
||||
/// sentinels were rare, and the slice wire made every ordinary AU one — after which the budget
|
||||
/// was spent on ~3 frames and everything else on the link was dropped. A sentinel that claims a
|
||||
/// lot must still be bounded: its wire base can point near the frame ceiling, which is the
|
||||
/// amplification this budget exists for.
|
||||
#[test]
|
||||
fn streamed_open_amplification_is_budget_bounded() {
|
||||
let mut r = Reassembler::new(limits());
|
||||
fn streamed_open_commits_its_own_extent_and_stays_bounded() {
|
||||
let coder = coder_for(FecScheme::Gf8);
|
||||
// limits(): shard 16 B, max_data_shards 8, max_frame_bytes 4096 → budget = 4 × 4096.
|
||||
// Modest legacy sentinels (block 0, full K = 8 → 128 B each): far more than
|
||||
// IN_FLIGHT_BUF_FACTOR of them must fit, because none of them claims the ceiling.
|
||||
let mut r = Reassembler::new(limits());
|
||||
let stats = StatsCounters::default();
|
||||
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
|
||||
for fi in 0..5u32 {
|
||||
for fi in 0..32u32 {
|
||||
let mut h = base_header();
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 0;
|
||||
@@ -1498,10 +1509,35 @@ fn streamed_open_amplification_is_budget_bounded() {
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
0,
|
||||
"ordinary one-datagram opens must not exhaust the in-flight budget"
|
||||
);
|
||||
|
||||
// A SLICE sentinel whose wire base sits just under the ceiling really does commit a
|
||||
// max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B) — four fit the budget, the
|
||||
// fifth must be refused.
|
||||
let mut r = Reassembler::new(limits());
|
||||
let stats = StatsCounters::default();
|
||||
for fi in 0..5u32 {
|
||||
let mut h = base_header();
|
||||
h.user_flags = USER_FLAG_SLICE_STREAM;
|
||||
h.block_count = 0;
|
||||
h.frame_bytes = 4096 - 8 * 16;
|
||||
h.block_index = 1;
|
||||
h.data_shards = 8;
|
||||
h.recovery_shards = 0;
|
||||
h.frame_index = fi;
|
||||
assert!(r
|
||||
.push(&packet(h), coder.as_ref(), &stats)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
stats.snapshot().packets_dropped,
|
||||
1,
|
||||
"the fifth max-sized open must be refused by the in-flight budget"
|
||||
"the fifth ceiling-claiming open must be refused by the in-flight budget"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1612,3 +1648,124 @@ fn streamed_second_final_with_different_totals_is_rejected() {
|
||||
.expect("frame completes under the first pinned totals");
|
||||
assert_eq!(got.data.len(), 160);
|
||||
}
|
||||
|
||||
/// Production-shaped slice geometry: a 1500-MTU shard payload and the smallest frame ceiling
|
||||
/// the QUIC handshake ever negotiates (`max_frame_bytes` is clamped to ≥ 8 MiB there).
|
||||
fn prod_slice_config() -> Config {
|
||||
use crate::config::{FecConfig, ProtocolPhase, Role};
|
||||
Config {
|
||||
role: Role::Host,
|
||||
phase: ProtocolPhase::P2Punktfunk,
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
fec_percent: 20,
|
||||
max_data_per_block: 200,
|
||||
},
|
||||
shard_payload: crate::config::mtu1500_shard_payload(),
|
||||
max_frame_bytes: 8 << 20,
|
||||
encrypt: false,
|
||||
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||
salt: [0u8; 4],
|
||||
loopback_drop_period: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Packetize one streamed AU of `chunks`, each chunk an encoder slice boundary.
|
||||
fn streamed_packets_with(
|
||||
cfg: &Config,
|
||||
frame_index: u32,
|
||||
pts_ns: u64,
|
||||
slice: bool,
|
||||
chunks: &[usize],
|
||||
) -> (Vec<Vec<u8>>, Vec<u8>) {
|
||||
let coder = coder_for(cfg.fec.scheme);
|
||||
let mut pk = Packetizer::new(cfg);
|
||||
let uf = if slice { USER_FLAG_SLICE_STREAM } else { 0 };
|
||||
let mut au = pk.begin_streamed(pts_ns, uf, Some(frame_index));
|
||||
let (mut pkts, mut src) = (Vec::new(), Vec::new());
|
||||
let sink = |pkts: &mut Vec<Vec<u8>>, h: &PacketHeader, b: &[u8]| {
|
||||
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
|
||||
p.extend_from_slice(h.as_bytes());
|
||||
p.extend_from_slice(b);
|
||||
pkts.push(p);
|
||||
};
|
||||
for (c, &n) in chunks.iter().enumerate() {
|
||||
let data: Vec<u8> = (0..n).map(|i| (c * 57 + i * 131 + 7) as u8).collect();
|
||||
src.extend_from_slice(&data);
|
||||
pk.push_streamed(&mut au, &data, true, coder.as_ref(), |h, b| {
|
||||
sink(&mut pkts, h, b);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
pk.finish_streamed(au, coder.as_ref(), |h, b| {
|
||||
sink(&mut pkts, h, b);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(pkts, src)
|
||||
}
|
||||
|
||||
/// An AU whose length is an exact multiple of the shard payload must still reassemble.
|
||||
///
|
||||
/// Regression: the slice flush drained `pending` to empty, so `finish_streamed` sealed a final
|
||||
/// block of one zero-padded FILLER shard. Its derived base (`total_data − 1`) overlapped the
|
||||
/// sentinel block flushed a moment earlier, the receiver's retro-validation read that as a lying
|
||||
/// header, and the whole AU was destroyed — one frame in every `shard_payload` (~12 s at 120 fps),
|
||||
/// each costing a re-anchor freeze and a recovery keyframe.
|
||||
#[test]
|
||||
fn slice_streamed_exact_shard_multiple_completes() {
|
||||
let cfg = prod_slice_config();
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
let payload = cfg.shard_payload;
|
||||
for shards in [16usize, 29, 30, 64] {
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[shards * payload]);
|
||||
// Whatever the block split, the final block must carry real bytes — never a lone
|
||||
// zero-pad shard sitting on top of the previous block's range.
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("{shards}-shard AU (exact multiple) must complete"));
|
||||
assert_eq!(f.data, src, "{shards}-shard AU must be byte-identical");
|
||||
}
|
||||
// ...and the sweep around one of them, so an off-by-one in the keep-back can't hide.
|
||||
for extra in 0..3usize {
|
||||
let n = 30 * payload + extra;
|
||||
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &[n]);
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
|
||||
.unwrap_or_else(|| panic!("{n}-byte AU must complete"));
|
||||
assert_eq!(f.data, src);
|
||||
}
|
||||
}
|
||||
|
||||
/// A slice-streamed frame must cost the reassembler its OWN size, not the negotiated ceiling.
|
||||
///
|
||||
/// Regression: sentinel-opened frames allocated `max_frame_bytes` (8-64 MiB) each. Since the
|
||||
/// slice wire makes every ordinary AU sentinel-opened, the in-flight budget
|
||||
/// (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`) was spent after ~3 concurrent frames and every
|
||||
/// packet of every further frame was dropped outright — a permanent loss storm on any link with
|
||||
/// normal reorder, plus a multi-megabyte zeroing per access unit.
|
||||
#[test]
|
||||
fn slice_streamed_in_flight_budget_matches_legacy() {
|
||||
let cfg = prod_slice_config();
|
||||
let coder = coder_for(FecScheme::Gf16);
|
||||
// A normal 40 KB access unit, opened but not completed — the shape a link with reorder
|
||||
// holds several of at once.
|
||||
for slice in [false, true] {
|
||||
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
|
||||
let stats = StatsCounters::default();
|
||||
for i in 0..12u32 {
|
||||
let (pkts, _) = streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
|
||||
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
stats
|
||||
.packets_dropped
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
0,
|
||||
"slice={slice}: 12 ordinary AUs in flight must fit the in-flight budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,105 @@
|
||||
//! Circular (directional) statistics for phase-locked capture (design/phase-locked-capture.md):
|
||||
//! the client-side half of the controller's v2 error signal. Pure math, no features — shared so
|
||||
//! every vsync-aware presenter (Android today, iOS next) computes the SAME statistic the host
|
||||
//! the client-side half of the controller's v2 error signal, plus the panel-grid learner every
|
||||
//! vsync-aware presenter paces against. Pure math, no features — shared so every presenter
|
||||
//! (Android today, iOS and the desktop session client next) computes the SAME statistic the host
|
||||
//! controller was tuned against, and so the controller's simulation tests can generate their
|
||||
//! synthetic reports through the identical code path.
|
||||
|
||||
/// Plausible panel periods: ~24 Hz to ~500 Hz. A spacing outside this is a clock glitch, not a
|
||||
/// display mode, and must never reach the estimate.
|
||||
const PANEL_PERIOD_RANGE_NS: std::ops::RangeInclusive<i64> = 2_000_000..=42_000_000;
|
||||
|
||||
/// Spacings within this of the estimate are the same grid — absorbs ordinary timeline jitter.
|
||||
const PANEL_GRID_TOLERANCE_NS: i64 = 200_000;
|
||||
|
||||
/// Consecutive WIDER observations required before the estimate grows. One stray wide sample is a
|
||||
/// scheduling hiccup; eight in a row (~66 ms at 120 Hz) is a display that really did slow down.
|
||||
const PANEL_WIDEN_STREAK: u8 = 8;
|
||||
|
||||
/// The panel's true refresh period, learned from observed vsync/frame-timeline spacing.
|
||||
///
|
||||
/// A presenter subdivides its release targets onto this grid, so an estimate FINER than the panel
|
||||
/// makes it aim at instants that never arrive and release faster than the display consumes —
|
||||
/// which is why the estimate has to be able to move both ways.
|
||||
///
|
||||
/// Seeding is the reason this is not simply "believe the last sample". The platform's *configured*
|
||||
/// mode is not the panel: under a per-uid frame-rate override a 120 Hz panel reports 60
|
||||
/// (`Display.getRefreshRate` returns the override — observed on-glass, A024), and the app's own
|
||||
/// choreographer callbacks arrive at the down-rated rate while the panel scans at its own. The
|
||||
/// mode TABLE is honest about what the panel *can* do, so it is the seed; the timeline spacing is
|
||||
/// honest about what it is *doing*, so it is the correction.
|
||||
///
|
||||
/// The asymmetry is deliberate. **Narrowing is immediate**: a finer real grid is always safe to
|
||||
/// subdivide onto, and it is the down-rate case the seed most often gets wrong. **Widening needs
|
||||
/// [`PANEL_WIDEN_STREAK`] consecutive agreeing observations** and then adopts the *narrowest* of
|
||||
/// them, because a wide sample is far more likely to be a missed callback than a mode change.
|
||||
///
|
||||
/// ⚠ 0.23.0 shipped this learner as narrow-only, seeded from the display mode the app *requests*
|
||||
/// (`preferredDisplayModeId` is a hint the system may refuse). A refused 120 Hz switch therefore
|
||||
/// left the presenter pacing a 60 Hz panel on an 8.33 ms grid with no way back — permanently.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PanelGrid {
|
||||
period_ns: i64,
|
||||
widen_streak: u8,
|
||||
/// Narrowest wider-than-estimate spacing seen during the current streak.
|
||||
widen_candidate: i64,
|
||||
}
|
||||
|
||||
impl PanelGrid {
|
||||
/// Seed from the display mode's refresh rate (`0` = unknown — the first plausible observation
|
||||
/// then sets the estimate outright).
|
||||
pub fn seeded(hz: i32) -> PanelGrid {
|
||||
PanelGrid {
|
||||
period_ns: if hz > 0 { 1_000_000_000 / hz as i64 } else { 0 },
|
||||
widen_streak: 0,
|
||||
widen_candidate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The learned period, or `0` while unknown.
|
||||
pub fn period_ns(&self) -> i64 {
|
||||
self.period_ns
|
||||
}
|
||||
|
||||
/// Fold one observed grid spacing. Returns `true` when [`period_ns`](Self::period_ns) changed.
|
||||
pub fn observe(&mut self, spacing_ns: i64) -> bool {
|
||||
if !PANEL_PERIOD_RANGE_NS.contains(&spacing_ns) {
|
||||
return false; // implausible — a clock glitch, not a display mode
|
||||
}
|
||||
if self.period_ns == 0 {
|
||||
self.reset_streak();
|
||||
self.period_ns = spacing_ns;
|
||||
return true;
|
||||
}
|
||||
if spacing_ns < self.period_ns - PANEL_GRID_TOLERANCE_NS {
|
||||
self.reset_streak();
|
||||
self.period_ns = spacing_ns;
|
||||
return true;
|
||||
}
|
||||
if spacing_ns > self.period_ns + PANEL_GRID_TOLERANCE_NS {
|
||||
self.widen_streak = self.widen_streak.saturating_add(1);
|
||||
self.widen_candidate = if self.widen_candidate == 0 {
|
||||
spacing_ns
|
||||
} else {
|
||||
self.widen_candidate.min(spacing_ns)
|
||||
};
|
||||
if self.widen_streak >= PANEL_WIDEN_STREAK {
|
||||
self.period_ns = self.widen_candidate;
|
||||
self.reset_streak();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.reset_streak(); // this sample agreed — the run of wider ones is broken
|
||||
false
|
||||
}
|
||||
|
||||
fn reset_streak(&mut self) {
|
||||
self.widen_streak = 0;
|
||||
self.widen_candidate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular (vector-mean) statistics of latch samples against a display period: the mean latch
|
||||
/// mod the period (ns) and the coherence (‰).
|
||||
///
|
||||
@@ -90,3 +186,111 @@ mod tests {
|
||||
assert!(circular_latch(&[1_000; 16], 0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panel_grid_tests {
|
||||
use super::*;
|
||||
|
||||
const P120: i64 = 8_333_333;
|
||||
const P60: i64 = 16_666_666;
|
||||
|
||||
#[test]
|
||||
fn seeds_from_the_mode_and_reports_unknown_without_one() {
|
||||
assert_eq!(PanelGrid::seeded(120).period_ns(), 8_333_333);
|
||||
assert_eq!(PanelGrid::seeded(0).period_ns(), 0);
|
||||
let mut g = PanelGrid::seeded(0);
|
||||
assert!(
|
||||
g.observe(P120),
|
||||
"the first plausible sample sets an unseeded grid"
|
||||
);
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrows_immediately_when_the_panel_is_faster_than_the_mode_said() {
|
||||
// The down-rate case: the mode table read 60, the timelines run at 120.
|
||||
let mut g = PanelGrid::seeded(60);
|
||||
assert!(g.observe(P120));
|
||||
assert_eq!(g.period_ns(), P120, "a finer real grid is adopted at once");
|
||||
}
|
||||
|
||||
/// The 0.23.0 bug: `preferredDisplayModeId` is a request, so a refused 120 Hz switch seeds a
|
||||
/// 120 Hz grid on a panel that is really running 60. The narrow-only learner could never
|
||||
/// climb back, and the presenter aimed at instants the panel never reached.
|
||||
#[test]
|
||||
fn widens_back_out_when_the_requested_mode_was_refused() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for i in 0..PANEL_WIDEN_STREAK - 1 {
|
||||
assert!(!g.observe(P60), "sample {i} must not widen on its own");
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
assert!(
|
||||
g.observe(P60),
|
||||
"a sustained run of wider spacings widens the grid"
|
||||
);
|
||||
assert_eq!(g.period_ns(), P60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_stray_wide_sample_never_widens() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for _ in 0..40 {
|
||||
assert!(!g.observe(P60));
|
||||
assert!(!g.observe(P120)); // an agreeing sample breaks the run
|
||||
}
|
||||
assert_eq!(
|
||||
g.period_ns(),
|
||||
P120,
|
||||
"alternating samples must not accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widening_adopts_the_narrowest_of_the_run() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
// A run of wide spacings that includes some very wide outliers.
|
||||
let run = [
|
||||
P60,
|
||||
33_000_000,
|
||||
P60 + 400_000,
|
||||
41_000_000,
|
||||
P60,
|
||||
P60,
|
||||
P60,
|
||||
P60,
|
||||
];
|
||||
for s in run {
|
||||
g.observe(s);
|
||||
}
|
||||
assert_eq!(
|
||||
g.period_ns(),
|
||||
P60,
|
||||
"the estimate takes the narrowest of the run, never an outlier"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implausible_spacings_are_ignored_entirely() {
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
for _ in 0..100 {
|
||||
assert!(!g.observe(0));
|
||||
assert!(!g.observe(-1));
|
||||
assert!(!g.observe(1_000_000)); // 1000 Hz — below the range floor
|
||||
assert!(!g.observe(100_000_000)); // 10 Hz — above the ceiling
|
||||
}
|
||||
assert_eq!(g.period_ns(), P120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transient_narrow_glitch_self_heals() {
|
||||
// Narrowing is immediate, so a glitch DOES poison the estimate — the point is that it is
|
||||
// no longer permanent (0.23.0's learner had no way back).
|
||||
let mut g = PanelGrid::seeded(120);
|
||||
assert!(g.observe(2_100_000), "a glitch narrows the estimate");
|
||||
assert_eq!(g.period_ns(), 2_100_000);
|
||||
for _ in 0..PANEL_WIDEN_STREAK {
|
||||
g.observe(P120);
|
||||
}
|
||||
assert_eq!(g.period_ns(), P120, "and the real grid wins it back");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,18 @@ impl Session {
|
||||
self.stats.snapshot()
|
||||
}
|
||||
|
||||
/// Re-arm the probe-scoped arrival stamps (see [`Stats::probe_first_arrival_ns`]): zero
|
||||
/// them so the NEXT burst's first packet claims the first-arrival slot. Called by the
|
||||
/// client pump when it arms a probe, strictly before the burst can have reached the host
|
||||
/// (the `ProbeRequest` is still queued locally) — so the reset cannot race a probe packet.
|
||||
/// The cumulative probe byte/packet counters are left alone: per-burst deltas come from
|
||||
/// base snapshots, the same pattern the total counters use.
|
||||
pub fn reset_probe_arrivals(&self) {
|
||||
let l = std::sync::atomic::Ordering::Relaxed;
|
||||
self.stats.probe_first_arrival_ns.store(0, l);
|
||||
self.stats.probe_last_arrival_ns.store(0, l);
|
||||
}
|
||||
|
||||
/// Wrap a packet for the wire: when encrypting, prepend the 8-byte big-endian
|
||||
/// sequence (the receiver derives the GCM nonce from it) then the ciphertext.
|
||||
/// Seal one plaintext packet into the reused `wire` buffer in place (no allocation): the wire is
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
//! Live counters for the frame-pacing / quality logic and the web UI.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Monotonic now, in ns since an arbitrary process-wide epoch — the basis for the probe
|
||||
/// arrival stamps below. Monotonic on purpose: the stamps are only ever DIFFERENCED on this
|
||||
/// machine (the burst's receive interval), and a wall-clock step mid-burst — the exact event
|
||||
/// the clock re-sync machinery exists for — must not corrupt the one measurement the ABR
|
||||
/// ceiling is built from, so the CLOCK_REALTIME basis `pts_ns` uses is wrong here. Floored
|
||||
/// at 1 so a stamp can never collide with the 0 = "unset" sentinel.
|
||||
pub(crate) fn now_monotonic_ns() -> u64 {
|
||||
static EPOCH: OnceLock<Instant> = OnceLock::new();
|
||||
(EPOCH.get_or_init(Instant::now).elapsed().as_nanos() as u64).max(1)
|
||||
}
|
||||
|
||||
/// Immutable snapshot, copied across the C ABI as `PunktfunkStats`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -26,11 +39,29 @@ pub struct Stats {
|
||||
pub fec_late_shards: u64,
|
||||
pub bytes_sent: u64,
|
||||
pub bytes_received: u64,
|
||||
/// Probe-scoped receive counters: wire packets / plaintext bytes carrying
|
||||
/// [`FLAG_PROBE`](crate::packet::FLAG_PROBE) (speed-test filler), counted at the
|
||||
/// reassembler's probe routing decision. `bytes_received` counts EVERY accepted datagram,
|
||||
/// so a speed-test numerator built from it inherits whatever video was in flight around
|
||||
/// the burst — these keep video out of the probe math. Deliberately NOT mirrored into the
|
||||
/// C-ABI `PunktfunkStats` (probe measurements surface via `ProbeOutcome`).
|
||||
pub probe_packets_received: u64,
|
||||
pub probe_bytes_received: u64,
|
||||
/// First / last probe-packet arrival (monotonic ns, see [`now_monotonic_ns`]; 0 = none
|
||||
/// since the last probe arm). Their difference is the burst's client-side receive
|
||||
/// interval — the honest speed-test denominator: the host's send window closes while the
|
||||
/// switch/kernel queue toward the client is still draining, so dividing client bytes by
|
||||
/// the HOST duration overstates the link (a 1 GbE link "measured" 1266 Mbps). The client
|
||||
/// pump zeroes both when it arms a probe (`Session::reset_probe_arrivals`).
|
||||
pub probe_first_arrival_ns: u64,
|
||||
pub probe_last_arrival_ns: u64,
|
||||
}
|
||||
|
||||
/// Atomic accumulators owned by a [`Session`](crate::session::Session). Snapshot to
|
||||
/// [`Stats`] for readers. `Relaxed` ordering is fine: these are monotonic counters
|
||||
/// read for display, never used to synchronize other memory.
|
||||
/// read for display, never used to synchronize other memory. (The two probe arrival
|
||||
/// stamps are the exception — slots, not counters — but they carry no synchronization
|
||||
/// duty either: they are read hundreds of ms after the last write.)
|
||||
#[derive(Default)]
|
||||
pub struct StatsCounters {
|
||||
pub frames_submitted: AtomicU64,
|
||||
@@ -44,6 +75,10 @@ pub struct StatsCounters {
|
||||
pub fec_late_shards: AtomicU64,
|
||||
pub bytes_sent: AtomicU64,
|
||||
pub bytes_received: AtomicU64,
|
||||
pub probe_packets_received: AtomicU64,
|
||||
pub probe_bytes_received: AtomicU64,
|
||||
pub probe_first_arrival_ns: AtomicU64,
|
||||
pub probe_last_arrival_ns: AtomicU64,
|
||||
}
|
||||
|
||||
impl StatsCounters {
|
||||
@@ -66,6 +101,10 @@ impl StatsCounters {
|
||||
fec_late_shards: self.fec_late_shards.load(l),
|
||||
bytes_sent: self.bytes_sent.load(l),
|
||||
bytes_received: self.bytes_received.load(l),
|
||||
probe_packets_received: self.probe_packets_received.load(l),
|
||||
probe_bytes_received: self.probe_bytes_received.load(l),
|
||||
probe_first_arrival_ns: self.probe_first_arrival_ns.load(l),
|
||||
probe_last_arrival_ns: self.probe_last_arrival_ns.load(l),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::wiring_plan::{plan, Endpoint, Wiring};
|
||||
use super::wiring_plan::{self, plan, Endpoint, Wiring};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::Mutex;
|
||||
@@ -75,6 +75,33 @@ pub(crate) fn host_audio_requested() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some()
|
||||
}
|
||||
|
||||
/// One wiring pass plus the inputs the desktop-audio capture loop's failure handling needs:
|
||||
/// the endpoint-set fingerprint ([`wiring_plan::fingerprint`] — the wait key while a plan is
|
||||
/// unsatisfiable, snapshotted from the SAME enumeration the plan consumed so a device arriving
|
||||
/// mid-pass can't leave the waiter keyed to a set the plan never saw) and the render inventory
|
||||
/// (the one-shot "why is there no loopback" diagnosis).
|
||||
pub(crate) struct WiredPlan {
|
||||
pub wiring: Wiring,
|
||||
pub fingerprint: u64,
|
||||
pub renders: Vec<Endpoint>,
|
||||
}
|
||||
|
||||
/// Fingerprint of the CURRENT endpoint set (both directions) WITHOUT a wiring pass: enumeration
|
||||
/// and a hash — no plan, no default-device writes, no logs. This is the cheap poll the capture
|
||||
/// loop runs while waiting out a failure; the full [`wire_now`] only runs again once this moves.
|
||||
/// Must run on a COM-initialized thread, like [`wire_now`].
|
||||
pub(crate) fn endpoint_fingerprint() -> u64 {
|
||||
wiring_plan::fingerprint(
|
||||
&list_endpoints(Direction::Render),
|
||||
&list_endpoints(Direction::Capture),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`wire_now_full`] for callers that only need the assignment (the mic paths).
|
||||
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
wire_now_full(set_playback).wiring
|
||||
}
|
||||
|
||||
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
|
||||
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
|
||||
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
|
||||
@@ -83,14 +110,20 @@ pub(crate) fn host_audio_requested() -> bool {
|
||||
/// Must run on a COM-initialized thread (the WASAPI worker threads all `initialize_mta` first).
|
||||
/// Logged only when the assignment changes, so per-open recomputation stays quiet in the steady
|
||||
/// state.
|
||||
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
|
||||
recover_orphaned_default();
|
||||
let renders = list_endpoints(Direction::Render);
|
||||
let captures = list_endpoints(Direction::Capture);
|
||||
let fingerprint = wiring_plan::fingerprint(&renders, &captures);
|
||||
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
|
||||
.ok()
|
||||
.map(|s| s.to_lowercase());
|
||||
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
|
||||
let done = |wiring: Wiring| WiredPlan {
|
||||
wiring,
|
||||
fingerprint,
|
||||
renders: renders.clone(),
|
||||
};
|
||||
|
||||
// Log assignment changes exactly once (first plan included).
|
||||
static LAST: Mutex<Option<Wiring>> = Mutex::new(None);
|
||||
@@ -105,14 +138,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
mic_render = wiring.mic_render.as_ref().map(|(n, _)| n.as_str()),
|
||||
mic_capture = wiring.mic_capture.as_ref().map(|(n, _)| n.as_str()),
|
||||
loopback_render = wiring.loopback_render.as_ref().map(|(n, _)| n.as_str()),
|
||||
loopback_last_resort = wiring.loopback_last_resort,
|
||||
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
|
||||
"audio wiring plan"
|
||||
);
|
||||
if wiring.mic_render.is_some() && wiring.loopback_render.is_none() {
|
||||
if wiring.mic_render.is_some() && wiring.loopback_unsatisfiable() {
|
||||
// Inventory + per-endpoint reasons + ONLY the remedies not already taken — the old
|
||||
// static advice here suggested installing the Steam pair to a field box that had it
|
||||
// installed (its Microphone half was exactly what the mic had reserved).
|
||||
tracing::warn!(
|
||||
"the virtual mic reserved the only usable render endpoint — desktop audio will be \
|
||||
unavailable until another output device exists (attach one, or let the host \
|
||||
install the Steam Streaming pair)"
|
||||
"desktop audio unavailable: {}",
|
||||
wiring_plan::describe_no_loopback(&renders, &wiring)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +159,7 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
"PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched"
|
||||
);
|
||||
}
|
||||
return wiring;
|
||||
return done(wiring);
|
||||
}
|
||||
// Default-playback hygiene, on EVERY wire (mic pump at boot included): if the default render
|
||||
// endpoint IS the mic target — VB-CABLE installs have been seen grabbing the default — every
|
||||
@@ -160,18 +196,26 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
|
||||
}
|
||||
}
|
||||
if let Some((name, id)) = &wiring.mic_capture {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally
|
||||
// fires SetDefaultEndpoint for all three roles (an audio-policy write plus a
|
||||
// device-graph notification, each). Re-asserting on every wiring pass therefore both
|
||||
// churned the policy store AND silently stomped an operator's own recording-device
|
||||
// choice within one reopen cycle — write only when the plan changed or the default
|
||||
// actually drifted off the target.
|
||||
if changed || default_capture_id().as_deref() != Some(id.as_str()) {
|
||||
match set_default_endpoint(id) {
|
||||
Ok(()) => {
|
||||
if changed {
|
||||
tracing::info!(device = %name,
|
||||
"audio wiring: default recording = virtual mic (apps record the client's mic)");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
|
||||
"audio wiring: failed to set the default recording device"),
|
||||
}
|
||||
}
|
||||
wiring
|
||||
done(wiring)
|
||||
}
|
||||
|
||||
/// The operator's default playback endpoint while we have it parked on the loopback sink:
|
||||
@@ -194,6 +238,18 @@ fn default_render_id() -> Option<String> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The current default CAPTURE endpoint id, if any — the recording-side analogue of
|
||||
/// [`default_render_id`], read before asserting the recording default so an already-correct
|
||||
/// default costs zero IPolicyConfig writes.
|
||||
fn default_capture_id() -> Option<String> {
|
||||
wasapi::DeviceEnumerator::new()
|
||||
.ok()?
|
||||
.get_default_device(&Direction::Capture)
|
||||
.ok()?
|
||||
.get_id()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Once per process: if a crash marker from a previous run exists, the host died while the
|
||||
/// playback default was parked — put the operator's device back, but only if the default still
|
||||
/// IS the endpoint we set (a manual change since the crash wins). Runs on the first wiring pass
|
||||
|
||||
@@ -18,9 +18,14 @@
|
||||
//! device changing under us — the operator picked a different output mid-stream — and reacts:
|
||||
//! a loopback-capturable choice is FOLLOWED (their explicit choice wins; audio then also plays
|
||||
//! on the host), a known-dud choice (cable/Steam Speakers/the mic target) snaps back to the
|
||||
//! plan. Device errors (endpoint invalidated, engine restart) reopen with backoff instead of
|
||||
//! killing audio for the rest of the session. On thread exit (capturer dropped at stream end)
|
||||
//! the parked default playback device is restored.
|
||||
//! plan. Device errors (endpoint invalidated, engine restart) reopen with a capped exponential
|
||||
//! backoff that an endpoint-set change cuts short. A plan with NO loopback endpoint at all is
|
||||
//! never retried: `wiring_plan::plan` is pure in the endpoint set, so that verdict holds until
|
||||
//! the set changes — the thread says why once, then parks on a cheap fingerprint poll and
|
||||
//! re-plans the instant the set moves (the 2026-08 field case hammered a full wiring pass —
|
||||
//! IPolicyConfig writes included — every 2 s for 8+ minutes without ever being able to
|
||||
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
|
||||
//! device is restored.
|
||||
|
||||
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
@@ -131,8 +136,20 @@ enum Next {
|
||||
Reopen(TargetMode),
|
||||
}
|
||||
|
||||
/// Backoff between self-heal reopen attempts after a capture failure.
|
||||
const REOPEN_BACKOFF: Duration = Duration::from_secs(2);
|
||||
/// Reopen backoff after a TRANSIENT capture failure: starts here and doubles per consecutive
|
||||
/// failure up to [`REOPEN_BACKOFF_CAP`], resetting on success or on an endpoint-set change
|
||||
/// (mirrors the mic pump's `PUMP_TUNING` shape). The predecessor was a FLAT 2 s retry whose
|
||||
/// every attempt re-ran the full wiring pass, IPolicyConfig writes included — tolerable for a
|
||||
/// genuinely transient error, an 8-minute hammer in the 2026-08 field case where the failure
|
||||
/// was structural.
|
||||
const REOPEN_BACKOFF_START: Duration = Duration::from_secs(2);
|
||||
const REOPEN_BACKOFF_CAP: Duration = Duration::from_secs(60);
|
||||
/// Endpoint-set poll cadence while waiting out a failure (both the transient backoff sleep and
|
||||
/// the unsatisfiable-plan wait): one enumerate-and-hash per tick, nothing else. A fingerprint
|
||||
/// change ends the wait immediately — a (re)arrived endpoint (the display coming back, plugged
|
||||
/// headphones) is exactly the recovery moment — so recovery stays as fast as the old 2 s hammer
|
||||
/// without its side effects.
|
||||
const ENDPOINT_POLL_EVERY: Duration = Duration::from_secs(2);
|
||||
/// Watchdog cadence for "did the default render device change under us?" checks.
|
||||
const DEFAULT_CHECK_EVERY: Duration = Duration::from_secs(1);
|
||||
/// Total attempts for the FIRST open before its failure surfaces through the `ready` handshake.
|
||||
@@ -168,14 +185,30 @@ fn capture_thread(
|
||||
let mut mode = TargetMode::Assert;
|
||||
let mut failures: u64 = 0;
|
||||
let mut first_attempts: u32 = 0;
|
||||
let mut backoff = REOPEN_BACKOFF_START;
|
||||
// Endpoint-set fingerprint under which an unsatisfiable plan was already error-logged: an
|
||||
// unchanged set means an unchanged verdict (`wiring_plan::plan` is pure), so the diagnosis
|
||||
// is said once per topology — the field log drowned in 256+ copies of the same line.
|
||||
let mut unsat_logged: Option<u64> = None;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match capture_once(&tx, &stop, &mut ready, channels, mode) {
|
||||
Ok(Next::Stopped) => break,
|
||||
Ok(Next::Reopen(m)) => {
|
||||
mode = m;
|
||||
failures = 0;
|
||||
backoff = REOPEN_BACKOFF_START;
|
||||
unsat_logged = None;
|
||||
}
|
||||
Err(e) if ready.is_some() => {
|
||||
// An unsatisfiable PLAN cannot improve within the handshake window — the
|
||||
// once-per-process Steam-pair install already ran inside `capture_once` — so
|
||||
// fail the open now with the full diagnosis instead of spending the transient
|
||||
// retry budget on a structural verdict. The native plane owns first-open
|
||||
// retries and backs off on its own.
|
||||
if e.downcast_ref::<PlanUnsatisfiable>().is_some() {
|
||||
let _ = ready.take().unwrap().send(Err(anyhow!("{e:#}")));
|
||||
break;
|
||||
}
|
||||
first_attempts += 1;
|
||||
if first_attempts >= FIRST_OPEN_ATTEMPTS || stop.load(Ordering::Relaxed) {
|
||||
let _ = ready.take().unwrap().send(Err(anyhow!("{e:#}")));
|
||||
@@ -190,16 +223,43 @@ fn capture_thread(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failures += 1;
|
||||
if failures.is_power_of_two() {
|
||||
tracing::warn!(error = %format!("{e:#}"), count = failures,
|
||||
"audio loopback capture failed — reopening");
|
||||
}
|
||||
mode = TargetMode::Assert;
|
||||
// Backoff in stop-responsive slices.
|
||||
let until = Instant::now() + REOPEN_BACKOFF;
|
||||
while Instant::now() < until && !stop.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
if let Some(unsat) = e.downcast_ref::<PlanUnsatisfiable>() {
|
||||
// Structural: retrying against the same endpoints repeats the same verdict,
|
||||
// and every retry used to re-run the wiring pass — IPolicyConfig writes
|
||||
// included, stomping any operator default-recording change within 2 s. Say
|
||||
// why once per topology, then park on the cheap fingerprint poll; the set
|
||||
// changing IS the recovery moment and re-plans immediately.
|
||||
failures = 0;
|
||||
backoff = REOPEN_BACKOFF_START;
|
||||
if unsat_logged != Some(unsat.fingerprint) {
|
||||
unsat_logged = Some(unsat.fingerprint);
|
||||
tracing::error!(
|
||||
"desktop audio unavailable, and retrying cannot help until the \
|
||||
audio endpoint set changes — waiting for that change. {unsat}"
|
||||
);
|
||||
}
|
||||
if wait_endpoint_change(&stop, unsat.fingerprint, None) == EndpointWait::Stopped
|
||||
{
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
unsat_logged = None;
|
||||
failures += 1;
|
||||
if failures.is_power_of_two() {
|
||||
tracing::warn!(error = %format!("{e:#}"), count = failures,
|
||||
backoff_secs = backoff.as_secs(),
|
||||
"audio loopback capture failed — reopening after backoff");
|
||||
}
|
||||
// Capped exponential backoff, cut short (and reset) the moment the
|
||||
// endpoint set changes — a re-arrived device is the likeliest cure for
|
||||
// whatever killed the capture, and it must not wait out a 60 s sleep.
|
||||
let fp = audio_control::endpoint_fingerprint();
|
||||
match wait_endpoint_change(&stop, fp, Some(Instant::now() + backoff)) {
|
||||
EndpointWait::Stopped => break,
|
||||
EndpointWait::Changed => backoff = REOPEN_BACKOFF_START,
|
||||
EndpointWait::Elapsed => backoff = (backoff * 2).min(REOPEN_BACKOFF_CAP),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +270,75 @@ fn capture_thread(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A wiring plan with NO loopback endpoint, as a typed error: [`wiring_plan::plan`] is pure in
|
||||
/// the enumerated endpoint set, so unlike every other capture error this one is PERMANENT until
|
||||
/// the topology changes — retrying it is guaranteed futile (the 2026-08 field case retried it
|
||||
/// flat-out for 8+ minutes, one full wiring pass per retry). Carries the set's fingerprint
|
||||
/// (what the reopen loop waits on) and the full diagnosis: inventory, per-endpoint rejection
|
||||
/// reasons, and only the remedies not already taken.
|
||||
#[derive(Debug)]
|
||||
struct PlanUnsatisfiable {
|
||||
fingerprint: u64,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl PlanUnsatisfiable {
|
||||
fn from_plan(plan: &audio_control::WiredPlan) -> PlanUnsatisfiable {
|
||||
debug_assert!(plan.wiring.loopback_unsatisfiable());
|
||||
PlanUnsatisfiable {
|
||||
fingerprint: plan.fingerprint,
|
||||
detail: wiring_plan::describe_no_loopback(&plan.renders, &plan.wiring),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlanUnsatisfiable {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlanUnsatisfiable {}
|
||||
|
||||
/// How a [`wait_endpoint_change`] ended.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum EndpointWait {
|
||||
/// `stop` was set — the capturer is being dropped.
|
||||
Stopped,
|
||||
/// The endpoint-set fingerprint moved — re-plan NOW (this is the recovery moment).
|
||||
Changed,
|
||||
/// The deadline passed without a change (backoff waits only; `deadline: None` never ends
|
||||
/// this way).
|
||||
Elapsed,
|
||||
}
|
||||
|
||||
/// Stop-responsive wait that polls the endpoint-set fingerprint every [`ENDPOINT_POLL_EVERY`] —
|
||||
/// an enumerate-and-hash, no wiring pass, no IPolicyConfig writes, no logs — until the set
|
||||
/// changes, `deadline` passes, or `stop` is set. `deadline: None` waits indefinitely: used while
|
||||
/// the plan is unsatisfiable, where ONLY a topology change can alter the verdict.
|
||||
fn wait_endpoint_change(
|
||||
stop: &AtomicBool,
|
||||
fingerprint: u64,
|
||||
deadline: Option<Instant>,
|
||||
) -> EndpointWait {
|
||||
let mut next_poll = Instant::now() + ENDPOINT_POLL_EVERY;
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return EndpointWait::Stopped;
|
||||
}
|
||||
if deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
return EndpointWait::Elapsed;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
if Instant::now() >= next_poll {
|
||||
next_poll = Instant::now() + ENDPOINT_POLL_EVERY;
|
||||
if audio_control::endpoint_fingerprint() != fingerprint {
|
||||
return EndpointWait::Changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current default render endpoint, with its id (`None` on any enumeration failure —
|
||||
/// transient failures must not kill the capture).
|
||||
fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> {
|
||||
@@ -220,7 +349,7 @@ fn default_render(en: &DeviceEnumerator) -> Option<(Device, String)> {
|
||||
|
||||
/// One endpoint open + capture loop. Returns how to continue ([`Next`]) or an error (first open:
|
||||
/// retried [`FIRST_OPEN_ATTEMPTS`] times, then fatal via the `ready` handshake; later: reopen
|
||||
/// with backoff).
|
||||
/// with capped backoff — or, for a typed [`PlanUnsatisfiable`], an endpoint-set wait).
|
||||
fn capture_once(
|
||||
tx: &SyncSender<Vec<f32>>,
|
||||
stop: &AtomicBool,
|
||||
@@ -233,26 +362,23 @@ fn capture_once(
|
||||
let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some();
|
||||
// Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default.
|
||||
let assert_plan = mode == TargetMode::Assert && !keep_default;
|
||||
let mut wiring = audio_control::wire_now(assert_plan);
|
||||
let mut plan = audio_control::wire_now_full(assert_plan);
|
||||
|
||||
// Client-only audio needs a silent-on-host sink with a working loopback (the Steam Streaming
|
||||
// Microphone's render side). If the plan had to settle for real hardware (or nothing), try —
|
||||
// once per process — to install the Steam pair (present when Steam is), then re-plan.
|
||||
if assert_plan && !audio_control::host_audio_requested() {
|
||||
let have_silent = wiring
|
||||
.loopback_render
|
||||
.as_ref()
|
||||
.is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase()));
|
||||
static INSTALL_TRIED: AtomicBool = AtomicBool::new(false);
|
||||
if !have_silent && !INSTALL_TRIED.swap(true, Ordering::SeqCst) {
|
||||
if super::wasapi_mic::install_steam_audio_pair() {
|
||||
wiring = audio_control::wire_now(true);
|
||||
}
|
||||
if !wiring
|
||||
.loopback_render
|
||||
let have_silent = |w: &wiring_plan::Wiring| {
|
||||
w.loopback_render
|
||||
.as_ref()
|
||||
.is_some_and(|(n, _)| wiring_plan::silent_sink(&n.to_lowercase()))
|
||||
{
|
||||
};
|
||||
static INSTALL_TRIED: AtomicBool = AtomicBool::new(false);
|
||||
if !have_silent(&plan.wiring) && !INSTALL_TRIED.swap(true, Ordering::SeqCst) {
|
||||
if super::wasapi_mic::install_steam_audio_pair() {
|
||||
plan = audio_control::wire_now_full(true);
|
||||
}
|
||||
if !have_silent(&plan.wiring) {
|
||||
tracing::info!(
|
||||
"no silent virtual sink for client-only audio — desktop audio will also play \
|
||||
on the host (install Steam, whose Remote Play streaming drivers provide one)"
|
||||
@@ -260,6 +386,12 @@ fn capture_once(
|
||||
}
|
||||
}
|
||||
}
|
||||
let wiring = &plan.wiring;
|
||||
// Only the Assert path can knowingly sit on the plan's LAST-RESORT endpoint: Follow captures
|
||||
// the operator's chosen default, and `judge_default` never routes Follow onto the Steam
|
||||
// Speakers (they are `excluded_from_loopback` — a Dud that snaps back to the plan).
|
||||
let last_resort = assert_plan && wiring.loopback_last_resort;
|
||||
let plan_fp = plan.fingerprint;
|
||||
|
||||
let en = DeviceEnumerator::new().context("DeviceEnumerator")?;
|
||||
// Resolve the endpoint to capture. ECHO GUARD (Follow/KEEP_DEFAULT shapes): the wiring plan
|
||||
@@ -268,11 +400,11 @@ fn capture_once(
|
||||
// fall back to the plan's loopback endpoint, or refuse — no desktop audio beats an echo loop.
|
||||
let (device, dev_name, dev_id) = if assert_plan {
|
||||
let Some(ep) = wiring.loopback_render.clone() else {
|
||||
anyhow::bail!(
|
||||
"no loopback-capturable render endpoint (every usable endpoint is reserved for \
|
||||
the virtual mic or has a silent loopback) — attach an output device or install \
|
||||
the Steam Streaming pair to get desktop audio"
|
||||
);
|
||||
// Detected BEFORE any open attempt, and typed: the plan is a pure function of the
|
||||
// endpoint set, so this cannot resolve until the set changes — the reopen loop
|
||||
// waits on the fingerprint instead of retrying (the old untyped bail was retried
|
||||
// flat-out every 2 s, forever, in the 2026-08 field case).
|
||||
return Err(PlanUnsatisfiable::from_plan(&plan).into());
|
||||
};
|
||||
let d = audio_control::open_endpoint(&ep)?;
|
||||
(d, ep.0, ep.1)
|
||||
@@ -285,10 +417,15 @@ fn capture_once(
|
||||
.is_some_and(|(_, mic_id)| *mic_id == id);
|
||||
if default_is_mic {
|
||||
let Some(lb) = wiring.loopback_render.clone() else {
|
||||
// Same inventory shape as the Assert bail, but NOT typed as unsatisfiable:
|
||||
// Follow's inputs include the DEFAULT device, which the operator can change
|
||||
// without a topology change (especially under PUNKTFUNK_KEEP_DEFAULT) — the
|
||||
// capped backoff must keep retrying rather than a fingerprint wait sleeping
|
||||
// through a default-only change.
|
||||
anyhow::bail!(
|
||||
"the only render endpoint is reserved for the virtual mic (capturing it would \
|
||||
echo the client's voice back) — attach another output device or install the \
|
||||
Steam Streaming pair to get desktop audio"
|
||||
"the default render endpoint is reserved for the virtual mic (capturing it \
|
||||
would echo the client's voice back) — {}",
|
||||
wiring_plan::describe_no_loopback(&plan.renders, wiring)
|
||||
);
|
||||
};
|
||||
tracing::warn!(mic = %wiring.mic_render.as_ref().unwrap().0, loopback = %lb.0,
|
||||
@@ -338,6 +475,7 @@ fn capture_once(
|
||||
}
|
||||
tracing::info!(device = %dev_name,
|
||||
follow = matches!(mode, TargetMode::Follow) || keep_default,
|
||||
last_resort,
|
||||
"audio loopback capturing");
|
||||
|
||||
// Watchdog seed: the default as it stands right after our open. In Assert mode the plan just
|
||||
@@ -349,7 +487,7 @@ fn capture_once(
|
||||
if assert_plan {
|
||||
if let Some(d) = seen_default.as_deref() {
|
||||
if d != dev_id {
|
||||
match judge_default(&en, &wiring, d) {
|
||||
match judge_default(&en, wiring, d) {
|
||||
DefaultKind::Capturable(name) => {
|
||||
tracing::info!(default = %name, planned = %dev_name,
|
||||
"could not park the default playback on the planned endpoint — \
|
||||
@@ -367,10 +505,12 @@ fn capture_once(
|
||||
|
||||
let mut bytes: VecDeque<u8> = VecDeque::new();
|
||||
let mut last_check = Instant::now();
|
||||
let mut last_fp_check = Instant::now();
|
||||
// Triage breadcrumb: a broken loopback (endpoint renders but its loopback tap delivers
|
||||
// nothing — the Steam Streaming Speakers failure shape) is indistinguishable from a simply
|
||||
// quiet desktop, so after 30 s with zero packets say so ONCE. Info, not warn: an idle host
|
||||
// is legitimately silent.
|
||||
// quiet desktop, so after 30 s with zero packets say so ONCE. Info, not warn — an idle host
|
||||
// is legitimately silent — EXCEPT on a last-resort endpoint, where the plan already knew
|
||||
// the loopback is silent and zero packets all but confirms the quality risk materialized.
|
||||
let opened_at = Instant::now();
|
||||
let mut saw_packets = false;
|
||||
let mut silence_noted = false;
|
||||
@@ -396,10 +536,18 @@ fn capture_once(
|
||||
}
|
||||
if !saw_packets && !silence_noted && opened_at.elapsed() >= Duration::from_secs(30) {
|
||||
silence_noted = true;
|
||||
tracing::info!(device = %dev_name,
|
||||
"no audio captured in the first 30 s — fine if the host is quiet; if it should \
|
||||
be playing audio, this endpoint's loopback may be broken (set \
|
||||
PUNKTFUNK_HOST_AUDIO=1 to prefer real hardware)");
|
||||
if last_resort {
|
||||
tracing::warn!(device = %dev_name,
|
||||
"no audio captured in the first 30 s from the LAST-RESORT loopback — the \
|
||||
Steam Streaming Speakers' loopback is known-silent, so desktop audio is \
|
||||
most likely not reaching the client; attach any output device to give the \
|
||||
plan a working endpoint (it re-plans on the change)");
|
||||
} else {
|
||||
tracing::info!(device = %dev_name,
|
||||
"no audio captured in the first 30 s — fine if the host is quiet; if it \
|
||||
should be playing audio, this endpoint's loopback may be broken (set \
|
||||
PUNKTFUNK_HOST_AUDIO=1 to prefer real hardware)");
|
||||
}
|
||||
}
|
||||
let whole = (bytes.len() / block_align) * block_align;
|
||||
if whole > 0 {
|
||||
@@ -428,7 +576,7 @@ fn capture_once(
|
||||
);
|
||||
return Ok(Next::Reopen(TargetMode::Follow));
|
||||
}
|
||||
return Ok(match judge_default(&en, &wiring, &nid) {
|
||||
return Ok(match judge_default(&en, wiring, &nid) {
|
||||
DefaultKind::Capturable(name) => {
|
||||
tracing::info!(device = %name,
|
||||
"operator changed the output device mid-stream — following \
|
||||
@@ -447,6 +595,24 @@ fn capture_once(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A LAST-RESORT capture is a stopgap, not a steady state: the plan chose the
|
||||
// known-silent Steam Speakers only because nothing better existed, so any endpoint-set
|
||||
// change — the display's audio endpoint re-arriving, headphones plugged in — may unlock
|
||||
// a real plan. Re-plan on the change; without this the session would ride the silent
|
||||
// loopback forever AFTER the real endpoint returned (the original field defect in a
|
||||
// quieter costume). Preferred endpoints don't get this watch: mid-stream re-routing
|
||||
// there is the default-device watchdog's job, on the operator's terms.
|
||||
if last_resort && last_fp_check.elapsed() >= ENDPOINT_POLL_EVERY {
|
||||
last_fp_check = Instant::now();
|
||||
if audio_control::endpoint_fingerprint() != plan_fp {
|
||||
audio_client.stop_stream().ok();
|
||||
tracing::info!(
|
||||
"endpoint set changed while capturing the last-resort loopback — re-planning"
|
||||
);
|
||||
return Ok(Next::Reopen(TargetMode::Assert));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,19 @@
|
||||
//! out of the host's speakers. Real hardware is the fallback (audio then plays on both ends).
|
||||
//! With `host_audio` (the `PUNKTFUNK_HOST_AUDIO` opt-in) the order flips back: real hardware
|
||||
//! first, so the operator hears the stream locally.
|
||||
//!
|
||||
//! **Last resort, and the honest failure.** When neither a silent sink nor real hardware
|
||||
//! survives the mic reservation, the Steam Streaming *Speakers* are taken as a flagged LAST
|
||||
//! resort ([`Wiring::loopback_last_resort`]): their loopback is known-silent (validated live) —
|
||||
//! a QUALITY risk the capture side warns about and treats as a stopgap — but holding a parked
|
||||
//! endpoint beats holding none (2026-08 field case: the display isolate invalidated the only
|
||||
//! real render endpoint mid-session, the mic held the Streaming Microphone, and a plan with no
|
||||
//! loopback left the session unrecoverable). Cables, VoiceMeeter strips and generically-
|
||||
//! "virtual" endpoints are never a last resort — capturing them re-captures what the mic writes,
|
||||
//! an echo/feedback CORRECTNESS risk, unlike silence — so with only those left the plan is
|
||||
//! honestly unsatisfiable ([`Wiring::loopback_unsatisfiable`]): a pure verdict on the endpoint
|
||||
//! set that cannot change until the set does. Callers must wait for an endpoint-set change
|
||||
//! ([`fingerprint`]), not retry.
|
||||
|
||||
/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
|
||||
pub(crate) type Endpoint = (String, String);
|
||||
@@ -42,6 +55,22 @@ pub(crate) struct Wiring {
|
||||
pub mic_capture: Option<Endpoint>,
|
||||
/// Render endpoint for the desktop-audio loopback; made the default playback device.
|
||||
pub loopback_render: Option<Endpoint>,
|
||||
/// `loopback_render` is the flagged LAST RESORT (the Steam Streaming Speakers, whose
|
||||
/// loopback is known-silent — validated live), taken only because nothing better survived
|
||||
/// the mic reservation. The capture side treats it as a stopgap: it warns when the silence
|
||||
/// materializes and re-plans on any endpoint-set change instead of riding it out.
|
||||
pub loopback_last_resort: bool,
|
||||
}
|
||||
|
||||
impl Wiring {
|
||||
/// This plan has NO loopback endpoint — not even the last resort. Because [`plan`] is pure,
|
||||
/// this is a STRUCTURAL verdict on the endpoint set, not a transient device error:
|
||||
/// reattempting a capture open without an endpoint-set change must fail identically (the
|
||||
/// 2026-08 field case spent 8+ minutes of flat 2 s retries proving exactly that). Callers
|
||||
/// wait for the set's [`fingerprint`] to move instead of retrying.
|
||||
pub(crate) fn loopback_unsatisfiable(&self) -> bool {
|
||||
self.loopback_render.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render-endpoint friendly-name substrings (lowercased) usable as the virtual-mic write target,
|
||||
@@ -134,7 +163,8 @@ pub(crate) fn plan(
|
||||
|
||||
// 3. Loopback from the REMAINING renders. Client-only (default): the silent sink (Steam
|
||||
// Streaming Microphone — its loopback works, unlike the Speakers') > real hardware
|
||||
// (audible fallback) > any non-excluded leftover. `host_audio`: real hardware first.
|
||||
// (audible fallback). `host_audio`: real hardware first. Either order can fall through
|
||||
// to the flagged last resort below.
|
||||
let not_mic = |id: &str| mic_render.as_ref().is_none_or(|(_, mid)| mid != id);
|
||||
let real_hw = || {
|
||||
renders.iter().find(|(n, id)| {
|
||||
@@ -147,29 +177,131 @@ pub(crate) fn plan(
|
||||
.iter()
|
||||
.find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
|
||||
};
|
||||
// `virtualish` here too: a virtual endpoint that slipped past `excluded_from_loopback`'s
|
||||
// name list (a future cable/mixer sibling) is an internal-feedback loop waiting to happen —
|
||||
// no loopback is the honest answer, exactly like the cable-only case.
|
||||
let leftover = || {
|
||||
renders.iter().find(|(n, id)| {
|
||||
let ln = n.to_lowercase();
|
||||
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln)
|
||||
})
|
||||
// LAST RESORT — the Steam Streaming Speakers, and ONLY them. Their loopback is known-silent
|
||||
// (validated live): a QUALITY risk, flagged so the capture side can warn when the silence
|
||||
// materializes and re-plan when the endpoint set changes — but a parked endpoint beats none
|
||||
// (2026-08: the display isolate invalidated the only real render endpoint mid-session and a
|
||||
// loopback-less plan left the session unrecoverable). Never a cable, a VoiceMeeter strip, or
|
||||
// a generically-"virtual" endpoint: those re-capture what the mic writes — echo/feedback
|
||||
// CORRECTNESS risks — so "no loopback" stays the honest answer there. NOTE
|
||||
// `excluded_from_loopback` itself stays untouched: it also powers the capture watchdog's
|
||||
// judgement of a NEW operator-chosen default, where admitting the Speakers would change
|
||||
// mid-stream snap-back semantics.
|
||||
let last_resort = || {
|
||||
renders
|
||||
.iter()
|
||||
.find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers"))
|
||||
};
|
||||
let loopback_render = if host_audio {
|
||||
real_hw().or_else(silent).or_else(leftover)
|
||||
let preferred = if host_audio {
|
||||
real_hw().or_else(silent)
|
||||
} else {
|
||||
silent().or_else(real_hw).or_else(leftover)
|
||||
}
|
||||
.cloned();
|
||||
silent().or_else(real_hw)
|
||||
};
|
||||
let (loopback_render, loopback_last_resort) = match preferred {
|
||||
Some(ep) => (Some(ep.clone()), false),
|
||||
None => match last_resort() {
|
||||
Some(ep) => (Some(ep.clone()), true),
|
||||
None => (None, false),
|
||||
},
|
||||
};
|
||||
|
||||
Wiring {
|
||||
mic_render,
|
||||
mic_capture,
|
||||
loopback_render,
|
||||
loopback_last_resort,
|
||||
}
|
||||
}
|
||||
|
||||
/// Order-independent fingerprint of an enumerated endpoint set. [`plan`] is a pure function of
|
||||
/// these inputs (the env knobs are process-stable), so an unchanged fingerprint PROVES an
|
||||
/// unchanged verdict: re-planning an unsatisfiable set before the fingerprint moves only repeats
|
||||
/// the same answer, with IPolicyConfig default-device writes as the side effect. The capture
|
||||
/// loop polls this instead of re-planning, and treats a change as the recovery moment.
|
||||
pub(crate) fn fingerprint(renders: &[Endpoint], captures: &[Endpoint]) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
// Each direction hashes as a length-prefixed sorted slice, so renders and captures cannot
|
||||
// alias each other and endpoint order (enumeration order churns) never matters.
|
||||
for eps in [renders, captures] {
|
||||
let mut sorted: Vec<&Endpoint> = eps.iter().collect();
|
||||
sorted.sort();
|
||||
sorted.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The one-shot diagnosis for a plan with no loopback endpoint: every enumerated render with WHY
|
||||
/// it was rejected, then ONLY the remedies not already taken. The static advice this replaces
|
||||
/// ("attach one, or let the host install the Steam Streaming pair") was already satisfied in the
|
||||
/// 2026-08 field case — the pair WAS installed, its Microphone half reserved by the mic — so the
|
||||
/// message pointed at a fix the box already had. Pure, like [`plan`]: callers pass the same
|
||||
/// enumeration the plan consumed.
|
||||
pub(crate) fn describe_no_loopback(renders: &[Endpoint], wiring: &Wiring) -> String {
|
||||
debug_assert!(wiring.loopback_unsatisfiable());
|
||||
let mic_id = wiring.mic_render.as_ref().map(|(_, id)| id.as_str());
|
||||
let rejected: Vec<String> = renders
|
||||
.iter()
|
||||
.map(|(name, id)| {
|
||||
let ln = name.to_lowercase();
|
||||
let why = if Some(id.as_str()) == mic_id {
|
||||
"reserved for the virtual mic (its loopback would echo the client's voice back)"
|
||||
} else if ln.contains("cable") {
|
||||
"virtual cable (its loopback re-captures what is written into it)"
|
||||
} else if ln.contains("voicemeeter") {
|
||||
"VoiceMeeter strip (shares the mixer the mic writes into — a feedback loop)"
|
||||
} else if ln.contains("steam streaming speakers") {
|
||||
// Reachable only when the Speakers ARE the mic target (operator override) —
|
||||
// the last-resort tier takes them otherwise.
|
||||
"known-silent loopback (validated live)"
|
||||
} else if ln.contains("virtual") {
|
||||
"unrecognized virtual endpoint (assumed feedback/silence risk)"
|
||||
} else {
|
||||
// `plan` accepts any non-virtual render — reaching this arm means a tier
|
||||
// changed without updating this diagnosis.
|
||||
"rejected by the wiring plan"
|
||||
};
|
||||
format!("{name:?}: {why}")
|
||||
})
|
||||
.collect();
|
||||
let inventory = if rejected.is_empty() {
|
||||
"no render endpoints exist at all".to_string()
|
||||
} else {
|
||||
rejected.join("; ")
|
||||
};
|
||||
let has = |needle: &str| {
|
||||
renders
|
||||
.iter()
|
||||
.any(|(n, _)| n.to_lowercase().contains(needle))
|
||||
};
|
||||
let mut remedies = vec!["attach any output device (headphones, or a monitor/TV with audio)"];
|
||||
// Only useful when the mic would actually vacate a loopback-capable endpoint: with the mic
|
||||
// on the Steam Streaming Microphone, a cable frees that silent sink for the loopback. A mic
|
||||
// on a VoiceMeeter strip frees nothing capturable, so the advice is withheld there.
|
||||
if !has("cable")
|
||||
&& wiring
|
||||
.mic_render
|
||||
.as_ref()
|
||||
.is_some_and(|(n, _)| silent_sink(&n.to_lowercase()))
|
||||
{
|
||||
remedies.push(
|
||||
"install VB-Audio Virtual Cable — the mic then takes the cable and frees the Steam \
|
||||
Streaming Microphone's render side for the loopback",
|
||||
);
|
||||
}
|
||||
if !has("steam streaming microphone") {
|
||||
remedies.push(
|
||||
"install Steam — its Remote Play streaming drivers add a loopback-capable virtual \
|
||||
sink",
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"no loopback-capturable render endpoint: {inventory}. Remedies: {}",
|
||||
remedies.join("; or ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -296,6 +428,10 @@ mod tests {
|
||||
w.loopback_render.unwrap().0,
|
||||
"Speakers (Steam Streaming Microphone)"
|
||||
);
|
||||
assert!(
|
||||
!w.loopback_last_resort,
|
||||
"the silent sink is a PREFERRED pick"
|
||||
);
|
||||
assert_eq!(
|
||||
w.mic_capture.unwrap().0,
|
||||
"CABLE Output (VB-Audio Virtual Cable)"
|
||||
@@ -330,16 +466,88 @@ mod tests {
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
/// Steam Streaming Speakers never become the loopback (silent loopback, validated live) —
|
||||
/// even when they're the only non-mic endpoint.
|
||||
/// Steam Streaming Speakers are never a PREFERRED loopback (their loopback is silent —
|
||||
/// validated live) — but when they are the only non-mic endpoint they ARE taken, flagged as
|
||||
/// the last resort: a silent loopback the capture side can warn about beats a plan with no
|
||||
/// endpoint at all (which is unrecoverable until the topology changes).
|
||||
#[test]
|
||||
fn steam_speakers_never_loopback() {
|
||||
fn steam_speakers_only_as_last_resort() {
|
||||
let renders = [
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
];
|
||||
let w = plan(&renders, &[], None, false);
|
||||
assert!(w.loopback_render.is_none());
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &[], None, host_audio);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Steam Streaming Speakers)",
|
||||
"host_audio={host_audio}"
|
||||
);
|
||||
assert!(w.loopback_last_resort, "host_audio={host_audio}");
|
||||
}
|
||||
}
|
||||
|
||||
/// THE 2026-08 field case: no cable, only the Steam pair left after the display isolate
|
||||
/// invalidated the monitor's DP audio endpoint. The mic reserves the Streaming Microphone
|
||||
/// (the only mic candidate), and the plan must then take the Speakers as the last resort —
|
||||
/// the old plan yielded no loopback here and the session never recovered.
|
||||
#[test]
|
||||
fn field_case_steam_pair_only_takes_speakers_as_last_resort() {
|
||||
let renders = [
|
||||
ep("Altavoces (Steam Streaming Speakers)"),
|
||||
ep("Altavoces (Steam Streaming Microphone)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
assert_eq!(
|
||||
w.mic_render.unwrap().0,
|
||||
"Altavoces (Steam Streaming Microphone)"
|
||||
);
|
||||
assert_eq!(
|
||||
w.loopback_render.unwrap().0,
|
||||
"Altavoces (Steam Streaming Speakers)"
|
||||
);
|
||||
assert!(w.loopback_last_resort);
|
||||
}
|
||||
|
||||
/// The last resort never shadows a real pick: with real hardware present the Speakers stay
|
||||
/// unchosen and the flag stays down, in both preference modes.
|
||||
#[test]
|
||||
fn last_resort_never_beats_real_hardware() {
|
||||
let renders = [
|
||||
ep("Speakers (Steam Streaming Microphone)"),
|
||||
ep("Speakers (Steam Streaming Speakers)"),
|
||||
ep("Speakers (Realtek HD Audio)"),
|
||||
];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &captures, None, host_audio);
|
||||
assert_eq!(
|
||||
w.loopback_render.as_ref().unwrap().0,
|
||||
"Speakers (Realtek HD Audio)",
|
||||
"host_audio={host_audio}"
|
||||
);
|
||||
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cables and VoiceMeeter strips are CORRECTNESS risks (they re-capture what the mic
|
||||
/// writes — echo/feedback), not quality risks: never the loopback, not even as a last
|
||||
/// resort. The plan stays honestly unsatisfiable.
|
||||
#[test]
|
||||
fn cable_and_voicemeeter_never_last_resort() {
|
||||
let renders = [
|
||||
ep("CABLE Input (VB-Audio Virtual Cable)"),
|
||||
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
|
||||
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
|
||||
];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
for host_audio in [false, true] {
|
||||
let w = plan(&renders, &captures, None, host_audio);
|
||||
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
|
||||
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
|
||||
assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Operator override beats the candidate order.
|
||||
@@ -413,9 +621,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A generically-"virtual" leftover (unknown vendor cable) is refused too: `leftover()`
|
||||
/// applies `virtualish`, so a virtual endpoint that slips past the name list can't become
|
||||
/// the loopback.
|
||||
/// A generically-"virtual" leftover (unknown vendor cable) is refused too: the last resort
|
||||
/// accepts ONLY the Steam Streaming Speakers, so a virtual endpoint that slips past
|
||||
/// `excluded_from_loopback`'s name list still can't become the loopback.
|
||||
#[test]
|
||||
fn unknown_virtual_never_loopback() {
|
||||
let renders = [
|
||||
@@ -425,4 +633,44 @@ mod tests {
|
||||
let w = plan(&renders, &[], None, false);
|
||||
assert!(w.loopback_render.is_none());
|
||||
}
|
||||
|
||||
/// The fingerprint keys the capture loop's "wait for an endpoint change" state: it must
|
||||
/// ignore enumeration order (Windows churns it), react to any topology change, and never
|
||||
/// alias the render and capture directions.
|
||||
#[test]
|
||||
fn fingerprint_order_independent_topology_sensitive() {
|
||||
let a = [ep("Speakers (Realtek HD Audio)"), ep("CABLE Input")];
|
||||
let a_rev = [ep("CABLE Input"), ep("Speakers (Realtek HD Audio)")];
|
||||
let caps = [ep("CABLE Output")];
|
||||
assert_eq!(fingerprint(&a, &caps), fingerprint(&a_rev, &caps));
|
||||
assert_ne!(fingerprint(&a, &caps), fingerprint(&a[..1], &caps));
|
||||
assert_ne!(fingerprint(&a, &caps), fingerprint(&caps, &a));
|
||||
}
|
||||
|
||||
/// The unsatisfiable-plan diagnosis must name what the mic reserved and advise ONLY the
|
||||
/// remedies not already taken: in the field case the Steam pair was installed (so "install
|
||||
/// Steam" would point at a fix the box already had) and the cable was missing (so VB-CABLE
|
||||
/// is the advice that actually frees the silent sink).
|
||||
#[test]
|
||||
fn describe_no_loopback_skips_satisfied_remedies() {
|
||||
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
|
||||
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
|
||||
let captures = [ep("Microphone (Steam Streaming Microphone)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
let msg = describe_no_loopback(&renders, &w);
|
||||
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
|
||||
assert!(msg.contains("VB-Audio Virtual Cable"), "{msg}");
|
||||
assert!(!msg.contains("install Steam"), "{msg}");
|
||||
|
||||
// Cable-only headless box: VB-CABLE is already installed (and freeing it wouldn't help
|
||||
// anyway), while the Steam pair is the remedy that adds a capturable sink.
|
||||
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
|
||||
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
|
||||
let w = plan(&renders, &captures, None, false);
|
||||
assert!(w.loopback_unsatisfiable());
|
||||
let msg = describe_no_loopback(&renders, &w);
|
||||
assert!(msg.contains("install Steam"), "{msg}");
|
||||
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +338,10 @@ pub(super) async fn negotiate(
|
||||
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
|
||||
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real
|
||||
// gate is `can_encode_444` (the full-res-chroma CSC variant existing on this OS).
|
||||
let capture_supports_444 = codec == crate::encode::Codec::PyroWave
|
||||
// Named for the whole capture→encoder INGEST chain, not the capturer: on Windows the
|
||||
// deciding fact is the encoder backend (direct NVENC only), and a field report burned
|
||||
// real time hunting a capture problem because the old `capture_supports_444` key said so.
|
||||
let ingest_chain_supports_444 = codec == crate::encode::Codec::PyroWave
|
||||
|| crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444());
|
||||
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
|
||||
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
|
||||
@@ -350,7 +353,7 @@ pub(super) async fn negotiate(
|
||||
crate::encode::Codec::H265 | crate::encode::Codec::PyroWave
|
||||
) && host_wants_444
|
||||
&& client_supports_444
|
||||
&& capture_supports_444
|
||||
&& ingest_chain_supports_444
|
||||
{
|
||||
tokio::task::spawn_blocking(move || crate::encode::can_encode_444(codec))
|
||||
.await
|
||||
@@ -358,6 +361,23 @@ pub(super) async fn negotiate(
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// The client's 4:4:4 setting IS the VIDEO_CAP_444 bit — when the user flipped it on and
|
||||
// the session still resolves 4:2:0, name the losing gate. (The PyroWave mode-size gate
|
||||
// below warns for itself.)
|
||||
if host_wants_444 && client_supports_444 && !gpu_supports_444 {
|
||||
let reason = if !matches!(
|
||||
codec,
|
||||
crate::encode::Codec::H265 | crate::encode::Codec::PyroWave
|
||||
) {
|
||||
"the negotiated codec only carries 4:2:0 — 4:4:4 needs HEVC or PyroWave"
|
||||
} else if !ingest_chain_supports_444 {
|
||||
"this host's encoder backend can't ingest full chroma — 4:4:4 needs direct \
|
||||
NVENC (NVIDIA) or the PyroWave codec"
|
||||
} else {
|
||||
"the GPU declined the 4:4:4 encode profile probe"
|
||||
};
|
||||
tracing::info!(reason, "4:4:4 requested but the session negotiates 4:2:0");
|
||||
}
|
||||
let chroma = if gpu_supports_444 {
|
||||
crate::encode::ChromaFormat::Yuv444
|
||||
} else {
|
||||
@@ -383,7 +403,7 @@ pub(super) async fn negotiate(
|
||||
chroma = ?chroma,
|
||||
host_wants_444,
|
||||
client_supports_444,
|
||||
capture_supports_444,
|
||||
ingest_chain_supports_444,
|
||||
"encode chroma"
|
||||
);
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ pub(super) fn synthetic_stream(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bounds a speed-test [`ProbeRequest`] before bursting: a 3 Gbps / 5 s ceiling keeps a probe from
|
||||
/// Bounds a speed-test [`ProbeRequest`] before bursting: a 10 Gbps / 5 s ceiling keeps a probe from
|
||||
/// monopolizing the link or stalling the stream for too long. The ceiling is set ABOVE the session
|
||||
/// bitrate cap ([`MAX_BITRATE_KBPS`], 2 Gbps) on purpose — a probe should be able to demonstrate
|
||||
/// headroom past the rate a session will actually be configured to use, so the client can pick a
|
||||
|
||||
@@ -241,6 +241,10 @@ A few knobs are read by the native **clients**, not the host:
|
||||
| `PUNKTFUNK_PREFER_PYROWAVE` | `1` | Ask for the [PyroWave](/docs/pyrowave) wavelet codec on a wired link, where the client's own setting isn't reachable (the gamepad console, a headless launch). |
|
||||
| `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. |
|
||||
| `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. |
|
||||
| `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. |
|
||||
| `PUNKTFUNK_ABR_PROBE_KBPS` | kbps, e.g. `900000` | The startup link-capacity probe's burst target (default 2 Gbps — deliberately above any plausible link so the burst measures the link, not itself). Lower it on links the burst shouldn't slam, or when the measured ceiling comes out wrong for your setup. |
|
||||
| `PUNKTFUNK_ABR_PROBE` | `0` | Skip the startup link-capacity probe entirely. The adaptive-bitrate climb ceiling then stays at the negotiated starting rate — a blunt instrument; prefer `PUNKTFUNK_ABR_MAX_MBPS`. |
|
||||
| `PUNKTFUNK_ABR_MAX_MBPS` | Mbps, e.g. `300` | Hard cap on the adaptive bitrate's climb ceiling, whatever the startup probe measured. The escape hatch when adaptive sessions keep climbing past what your client's **decoder** can sustain (periodic hitch + "receive backlog stopped draining" in the client log). An explicit bitrate setting still bypasses ABR entirely. |
|
||||
|
||||
## Bitrate
|
||||
|
||||
|
||||
@@ -31,6 +31,35 @@ even across a tag re-point.
|
||||
Canary / `-rc` builds have **no** file here on purpose: they get no curated body and are not
|
||||
announced.
|
||||
|
||||
## Google Play "What's new": `whatsnew/vX.Y.Z.txt`
|
||||
|
||||
Play shows its own release notes on the Play Store listing and in the Play Store app, and caps
|
||||
them at **500 characters per language** — the `vX.Y.Z.md` body is two orders of magnitude too
|
||||
long, so it gets its own short file: `docs/releases/whatsnew/vX.Y.Z.txt`.
|
||||
|
||||
Write it for a **phone/TV user**, not a host operator: only what changed in the Android app is
|
||||
worth their 500 characters. Plain text (Play renders no markdown), one `•` bullet per line, same
|
||||
voice rules as below. Copy `whatsnew/TEMPLATE.txt`.
|
||||
|
||||
**A `vX.Y.Z` tag without this file fails the android job before it builds.** This is a hard gate,
|
||||
not a warning, because the failure it prevents is silent: when the file is missing Play does not
|
||||
show an empty "What's new" — it **carries the previous release's text onto the new version**, so
|
||||
the store listing describes a build nobody is getting, and nothing surfaces that but reading the
|
||||
listing. It is the same shape as the v0.22.3 notes announcing a feature that release never
|
||||
contained. The gate also rejects a file byte-identical to another release's, which is that bug
|
||||
reached by copy-paste instead of by omission.
|
||||
|
||||
The gate runs first in the job, so a miss costs a second and leaves nothing half-published —
|
||||
no build, no assets on the Gitea release, nothing on Play. Two more checks sit downstream:
|
||||
`play-upload.py` refuses text over the 500-char cap (printing the real count) before it uploads,
|
||||
because the API only rejects oversized notes at commit, by which point the AAB is already on Play.
|
||||
|
||||
Canary is exempt: it has no curated notes, and Play reusing text for internal testers costs
|
||||
nothing.
|
||||
|
||||
Same freeze rule as the notes: once the tag exists, this file is the record of what that
|
||||
versionCode shipped.
|
||||
|
||||
## Voice & format
|
||||
|
||||
**Write for the people who USE Punktfunk to stream their games and desktops — not for the people who
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
• <The headline change, in what the user gets — not what was built.>
|
||||
• <Second thing. Name devices they recognize: a phone, an Android TV, a DualSense.>
|
||||
• <Third. Stop at what fits; 500 characters is the hard cap, and it is small.>
|
||||
|
||||
HOW TO USE THIS FILE
|
||||
Copy to vX.Y.Z.txt, replace every line, delete this block. It is skipped by the CI gate by
|
||||
name, so leaving it in place never blocks a release — but a copy that still says "<The
|
||||
headline change>" will ship to the Play Store exactly as written. Nothing checks your prose.
|
||||
|
||||
WHAT BELONGS HERE
|
||||
Only what changed in the ANDROID APP. Host-side work — Windows installers, gamescope, the web
|
||||
console — means nothing to someone reading a phone app's store listing, and it is spending
|
||||
characters you do not have. The full release notes in ../vX.Y.Z.md are where that lives.
|
||||
|
||||
RULES THE CI GATE ENFORCES (.gitea/workflows/android.yml)
|
||||
* The file must exist for a vX.Y.Z tag, or the release fails before it builds. Play does not
|
||||
show "no notes" when the file is missing — it carries the PREVIOUS release's text onto the
|
||||
new version, and nobody notices until they read the store listing.
|
||||
* It must not be byte-identical to another release's file.
|
||||
* play-upload.py refuses anything over 500 characters and prints the real count.
|
||||
|
||||
RULES NOTHING ENFORCES
|
||||
* Plain text. No markdown — Play renders none of it, so `**bold**` ships as asterisks.
|
||||
* One `•` bullet per line.
|
||||
* Same voice as ../README.md: lead with what the user can now do, no internal vocabulary.
|
||||
* Frozen once the tag exists. Editing it later does not change what already shipped; it only
|
||||
misleads the next person who reads it as the record of that versionCode.
|
||||
@@ -0,0 +1,4 @@
|
||||
• Much lower latency: a new frame scheduler cut glass-to-glass delay by about a third on the phones we measured, and the host can now line its capture up with your screen.
|
||||
• Microphone: mute it mid-stream, and echo cancellation is on by default.
|
||||
• DualSense controllers work over USB.
|
||||
• AV1 is picked automatically when your phone and host both support it.
|
||||
@@ -1767,7 +1767,11 @@ typedef struct {
|
||||
// Application goodput bytes / access units the host offered.
|
||||
uint64_t host_bytes;
|
||||
uint32_t host_packets;
|
||||
// The host's measured burst duration, milliseconds (the throughput denominator).
|
||||
// The throughput denominator, milliseconds: the client-measured burst receive interval
|
||||
// (first → last probe-packet arrival) once `done`; the host's measured send-window
|
||||
// duration when fewer than two probe packets arrived (no interval to measure from). The
|
||||
// host duration alone overstates throughput — its window closes while the bottleneck
|
||||
// queue is still draining toward the client.
|
||||
uint32_t elapsed_ms;
|
||||
// Delivered wire throughput = `recv_bytes * 8 / elapsed_ms` (kilobits/second).
|
||||
uint32_t throughput_kbps;
|
||||
@@ -2846,7 +2850,7 @@ PunktfunkStatus punktfunk_connection_wants_decode_latency(const PunktfunkConnect
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
||||
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 10 Gbps / ≤ 5 s),
|
||||
// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its
|
||||
// `done` field is 1. Starting a probe resets any prior measurement.
|
||||
//
|
||||
|
||||
@@ -109,10 +109,13 @@ static DS_FEATURE_PAIRING: [u8; 20] = [ // 0x09 pairing info (MAC at 1..7)
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
static DS_FEATURE_FIRMWARE: [u8; 64] = [ // 0x20 firmware info
|
||||
static DS_FEATURE_FIRMWARE: [u8; 64] = [ // 0x20 firmware info; bytes 44..46 = update version,
|
||||
// kept ABOVE Sony's real releases (0x0630 as of 2026-08) — an older value makes PlayStation
|
||||
// Accessories and libScePad titles demand a firmware update the virtual pad cannot take.
|
||||
// Mirrors inject/proto/dualsense_proto.rs DS_FEATURE_FIRMWARE; keep the two in sync.
|
||||
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
|
||||
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, 0x09, 0x00, 0x00,
|
||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
|
||||
@@ -108,16 +108,34 @@ trusted certificate, which is what the edge vhost provides in production.
|
||||
|
||||
1. **DNS** — `winget.punktfunk.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone
|
||||
(DNS-only, same as `docs`). ✅ *done*
|
||||
2. **Caddy vhost** on unom-1, next to the existing `docs.punktfunk.unom.io` block:
|
||||
2. **Caddy vhost** — in **`unom/infra`, `caddy/Caddyfile`**, next to the existing
|
||||
`docs.punktfunk.unom.io` block. ✅ *done*
|
||||
|
||||
```caddyfile
|
||||
winget.punktfunk.unom.io {
|
||||
reverse_proxy 127.0.0.1:3240
|
||||
import security_headers
|
||||
reverse_proxy localhost:3240
|
||||
}
|
||||
```
|
||||
|
||||
Until this exists the hostname resolves but the TLS handshake fails — Caddy has no certificate
|
||||
for a name it does not serve. That is the expected state, not a broken deploy.
|
||||
⚠ **Not by hand on the box.** `~/caddy/Caddyfile` on unom-1 looks like the config but is a copy
|
||||
that `deploy-all.sh` rsyncs over from `unom/infra`, and there is no `.git` there to warn you. A
|
||||
vhost added only on the box survives until the next deploy and no longer: this one was
|
||||
hand-added on 2026-07-26, and the 2026-07-31 hardening commit rewrote the file from the repo's
|
||||
own copy and deleted it — its message says "all 7 vhosts" when there were 8 live.
|
||||
|
||||
Until the vhost exists the hostname resolves but the TLS handshake fails — Caddy has no
|
||||
certificate for a name it does not serve. That is the expected state on first setup, not a
|
||||
broken deploy, but it is also exactly how the clobber presents later: an `internal_error`
|
||||
(alert 80) with no peer certificate, which winget reports as
|
||||
`WINHTTP_CALLBACK_STATUS_FLAG_SECURITY_CHANNEL_ERROR` / `0x8a15003b` and which looks like a
|
||||
client bug. Diagnose by SNI, not by port 80 — Caddy 308s *every* Host to https, including names
|
||||
it has never heard of, so a redirect there proves nothing:
|
||||
|
||||
```bash
|
||||
openssl s_client -connect winget.punktfunk.unom.io:443 \
|
||||
-servername winget.punktfunk.unom.io </dev/null 2>&1 | grep -E '^subject=|alert'
|
||||
```
|
||||
3. Run `deploy-services.yml` to place the compose file and start the container. It serves 503 until
|
||||
a catalogue exists — expected, and visible on `/healthz`.
|
||||
4. Publish a stable tag, or build and ship the catalogue by hand:
|
||||
|
||||
Reference in New Issue
Block a user