Compare commits

..
Author SHA1 Message Date
enricobuehler 071c4041e4 feat(client/android): tail the cadence loop's health on the ASC pf.present line
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / web (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 1m40s
ci / bun-nix (pull_request) Successful in 23s
android / android (pull_request) Successful in 18m42s
ci / rust (pull_request) Successful in 19m14s
The ASurfaceControl backend's 1 Hz pf.present line carried no cadence health, so
smooth mode was unobservable. Add late-permille / jitterMs / cushionMs / reanchors
(from CadenceClock::health, only under the smoothness intent) + the FIFO qDepth,
mirroring the SurfaceView presenter. This is what let the on-glass verification
attribute smooth-mode stutter to the Wi-Fi arrival-jitter TAIL (late tracks jitter
spikes, identical at 60 and 120 Hz), not the panel rate or a code bug — latency
stays the correct default; cadence pacing needs a clean/wired link.
2026-08-18 12:45:03 +02:00
enricobuehler 582759b3cb refactor(client/android): set the ASC layer rate once, not per transaction
ci / rust-arm64 (pull_request) Successful in 1m28s
ci / bun-nix (pull_request) Successful in 1m28s
ci / web (pull_request) Successful in 4m19s
android / android (pull_request) Successful in 4m43s
ci / docs-site (pull_request) Successful in 5m50s
ci / rust (pull_request) Successful in 17m10s
The layer's fixed-source frame-rate vote persists in SurfaceFlinger across
transactions, so re-asserting it on every present was redundant; the 'so a
governor that decays the vote keeps seeing it' rationale was disproven on the NP3
(no app-side rate hint raises its render-range floor either way). Set it once at
layer config, and correct the now-stale comment in the presenter. Behaviour
unchanged; one fewer FFI call per frame.

Split from the previous commit, which staged only the Kotlin revert.
2026-08-18 12:15:34 +02:00
enricobuehler baf011f7a7 refactor(client/android): drop the ineffective refresh-rate pins from the 120 Hz chase
Investigating why the NP3 wouldn't hold 120 Hz added window-level frame-rate pins
that turned out to do nothing and carried side effects, so they come back out.

Measured conclusion (NP3, dumpsys DisplayModeDirector): NO app-side API raises the
panel's render-range FLOOR — it stays min=0 under preferredDisplayModeId,
preferredRefreshRate, the per-layer setFrameRate vote, and
frameRatePowerSavingsBalanced alike, so the LTPO governor runs "video" content at 60
for power and only the system Minimum-refresh-rate setting (or a touch boost) lifts
it. A native GPU game (PPSSPP) sits at 60 at its menu for the same reason. This is
an OEM limitation, not a client defect, and the pins were both useless here and a
latent battery/rate side effect elsewhere:

- MainActivity.setStreamDisplayMode: reverted to `preferredDisplayModeId` only (its
  comment had also come to claim, falsely, that preferredRefreshRate pins min==max).
- surface_control: the layer's fixed-source rate vote is set ONCE at layer config,
  not re-asserted every transaction — the layer rate persists in SurfaceFlinger, and
  the "re-assert so the governor keeps seeing it" rationale was disproven.

Kept, because they are real fixes independent of the refresh cap: the ASurfaceControl
backend, the view-sized layer geometry, and the seed-period present-grid targeting
(which is what lets the picture reach a true 120 whenever the panel IS at 120 —
verified on-glass with the panel floored). The panelMs HUD readout that diagnosed all
of this stays.

Verified: cargo ndk arm64 check + clippy + rustfmt clean; gradle build + install green.
2026-08-18 12:15:13 +02:00
enricobuehler a823bcf6ae fix(client/android): present on the panel's real vsync grid, and pin the refresh rate best-effort
ci / rust-arm64 (pull_request) Successful in 1m38s
ci / web (pull_request) Successful in 2m53s
ci / bun-nix (pull_request) Successful in 2m43s
ci / docs-site (pull_request) Successful in 3m3s
android / android (pull_request) Successful in 5m28s
ci / rust (pull_request) Successful in 12m13s
Two frame-rate fixes on top of the ASurfaceControl backend, both found on-glass
(NP3, 2800x1260@120).

Present target: the presenter derived its desired-present-time grid from the panel
period it LEARNED from latch spacings — but the target produces the latch, so once
a couple of 60 Hz-spaced latches landed the learner flipped to 60 and then paced
every frame onto the 60 Hz grid, locking the panel there. A plain ASAP present did
not fix it either: applying two transactions close together let SurfaceFlinger
coalesce the pair onto one vsync and idle the next (also 60). The grid now uses the
mode-table SEED period (the honest panel maximum, fixed for the session) for its
spacing and the last real latch only for phase, so every frame claims its own
vsync. The latch-learned period is demoted to a HUD readout. Layer frame-rate vote
is now re-asserted on every transaction (FIXED_SOURCE) rather than once, so a
governor that decays a one-shot vote keeps seeing it.

Refresh-rate pin: alongside preferredDisplayModeId, the stream window now sets
preferredRefreshRate and clears frameRatePowerSavingsBalanced (API 34) — the
documented levers to hold an LTPO panel at the mode's rate. Restored on stream exit.

On-glass result: the picture reaches a clean 120 (panelMs 8.1, displays ~120, latch
p50 ~4.5, e2e p50 ~16 ms) but does NOT hold it on this device — Nothing OS's LTPO
governor keeps the app render-range floor at 0 in the DisplayModeDirector regardless
of every app-side API tried (mode id, preferredRefreshRate, power-savings flag, and
the per-layer Exact/FIXED_SOURCE vote), and decays "video" content to 60 after the
touch-boost window. That is a pre-existing OEM limitation that constrained the old
SurfaceView path too, not something the presenter can override; the hints stay as
best-effort for compliant panels. The latency + no-dropped-frames wins stand
regardless (e2e 30 -> ~18 ms, skipped 40-50/s -> 0).

Verified: cargo ndk arm64 check + clippy + rustfmt clean; installed and streamed on
the NP3.
2026-08-18 11:32:18 +02:00
enricobuehler 5c6236aec9 fix(client/android): size the ASurfaceControl layer to the view, not the window buffer
ci / rust-arm64 (pull_request) Successful in 1m59s
ci / docs-site (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 23s
android / android (pull_request) Canceled after 16m59s
ci / rust (pull_request) Canceled after 13m56s
ci / web (pull_request) Canceled after 11m18s
On-glass, the ASC layer drew the picture into the top-left ~45% of the screen.
The dest rectangle came from ANativeWindow_getWidth/getHeight on the SurfaceView,
which returned the window's buffer geometry in a rotated/scaled space (1260x567
for a 2800x1260 full-bleed stream) — the ASurfaceControl child is composited in
the SurfaceView's on-screen coordinate space (2800x1260), so a 1260x567 dest
shrank it to the corner.

The presenter now takes the SurfaceView's on-screen pixel size, plumbed from
Kotlin (surfaceCreated -> nativeStartVideo -> DecodeOptions), and uses it as the
dest rect. This is the aspect-fitted video footprint, so it stays correct under
letterboxing and render-scale too, where the decoded buffer size would not. A
non-positive value (view not laid out yet) falls back to the window buffer size.

Verified on glass (NP3 -> 2800x1260@120 HDR): "asc: layer created, dest 2800x1260"
and the picture fills the screen; e2e p50 ~15-19 ms, no policy skips. cargo ndk
arm64 check + clippy + rustfmt clean; Kotlin compiles via the gradle build that
installed it. (The panel still latching at 60 Hz is a separate frame-rate-pin
issue, not this fix.)
2026-08-18 11:04:41 +02:00
enricobuehler 95637f3226 feat(client/android): present through ASurfaceControl, scheduled on the panel's real clock
ci / bun-nix (pull_request) Successful in 40s
ci / docs-site (pull_request) Successful in 1m41s
ci / web (pull_request) Successful in 7m5s
ci / rust-arm64 (pull_request) Successful in 7m5s
ci / rust (pull_request) Successful in 7m57s
android / android (pull_request) Canceled after 9m19s
The field report was the Nothing Phone 3 — the exact device the latency program
was tuned on — reading e2e p50 30 ms / p95 39 ms with 40-50 skipped frames a
second, and switching to Smooth changed nothing. That is a present-path failure,
not a decode wall: this SoC decodes a frame in ~4-5 ms, 240 fps of headroom.
Dropping ~40% of frames by policy while the survivors land 1-2 refreshes late is
the signature of the presenter scheduling against a clock that lies. Two facts
underneath it: Android down-rates a game uid's choreographer stream (so the panel
grid the SurfaceView presenter learns can read 60 on a 120 panel), and its glass
budget reopens on a *predicted* latch that, when wrong, backpressures the codec.

The SurfaceView path can only predict SurfaceFlinger's latch and hope the
best-effort OnFrameRendered callbacks arrive. This adds the Android equivalent of
what the Apple client gets from CAMetalDisplayLink + preferredFrameLatency=1: the
codec renders into an AImageReader, and each frame is composited onto an
ASurfaceControl layer via a transaction carrying a desired present time. Every
applied transaction reports its *real* latch time and the previous buffer's
release fence on completion, so the panel period is learned from real latch
spacings (no down-rate lie), the glass budget is bounded by real completions (no
mispredicted reopen), and the display stage is always measured (not best-effort).

Both present intents ride the one actuator — the transaction's desired present
time: latency (default) is newest-wins at the next real vsync; smooth drains a
small FIFO on each frame's CadenceClock due time, now with a truthful clock
beneath the loop that could not lock before. The re-anchor gate, ABR decode
signal, decode-stage HUD split, and the audio plane's video-e2e reference all
keep their existing seams.

ASurfaceControl is the default. It falls back to the SurfaceView presenter,
byte-for-byte unchanged, on API < 29, any init failure (null layer, ImageReader),
or debug.punktfunk.present_backend=surfaceview (the field escape hatch, no
rebuild). Memory safety does not rest on the release fences — an AImage keeps its
buffer alive through SurfaceFlinger's own reference, so a mishandled fence at
worst reuses a buffer early (a tear), never a use-after-free — which is what lets
this default in behind the auto-fallback.

ASurfaceControl/ASurfaceTransaction are not in ndk-sys 0.6, so surface_control.rs
hand-declares them and resolves every entry point via dlsym from libandroid.so,
the same >-floor-symbol pattern adpf.rs and vsync.rs already use (all the
ASurface* entry points are API 29, above minSdk 28). AImageReader/AHardwareBuffer
come from the vendored ndk crate.

Verified: cargo ndk arm64-v8a + armeabi-v7a check, clippy, and the arm64 .so
links; rustfmt clean. NOT YET RUN ON A DEVICE — the on-glass A/B on the NP3 (both
modes, vs the SurfaceView backend via the sysprop) is the acceptance gate before
this is trusted as the default; the fallback makes shipping it default safe in
the meantime.
2026-08-18 10:47:08 +02:00
14 changed files with 1399 additions and 366 deletions
@@ -916,6 +916,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
// The SurfaceView's on-screen pixel size — the coordinate space the
// ASurfaceControl layer composites in (the aspect-fitted video rect,
// not the window's rotated buffer geometry). 0 if not laid out yet;
// native falls back to the window buffer size.
this@apply.width,
this@apply.height,
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
// The MIC grant is read live (a surface recreate re-runs this, and
@@ -283,6 +283,11 @@ object NativeBridge {
/** The display mode's own refresh rate (0 = unknown) — the latch grid the presenter
* subdivides onto when the platform down-rates the app's choreographer stream. */
panelFps: Int,
/** The video SurfaceView's on-screen pixel size (0 = not laid out yet). The ASurfaceControl
* present backend composites its layer in this coordinate space — the aspect-fitted display
* footprint — rather than the window's rotated/scaled buffer geometry. */
surfaceW: Int,
surfaceH: Int,
)
/** Stop + join the decode thread without closing the session. No-op on `0`. */
@@ -0,0 +1,610 @@
//! The ASurfaceControl present backend (default): MediaCodec → `AImageReader` → `ASurfaceControl`
//! transactions, scheduled against the panel's real present clock.
//!
//! Where the SurfaceView presenter ([`super::presenter`]) predicts SurfaceFlinger's latch off a
//! choreographer grid that Android down-rates for a game uid, this backend gets the truth: every
//! applied transaction reports its real latch time and the previous buffer's release fence on
//! completion ([`super::surface_control::PresentComplete`]). Those two facts are the whole point —
//! the panel period is learned from real latch spacings (no down-rate lie), the glass budget is
//! bounded by real completions (no mispredicted reopen backpressuring the codec), and the latch
//! metric is always available (not the best-effort `OnFrameRendered` the SurfaceView path leans on).
//!
//! Both present intents ride the one actuator — a desired present time on the transaction:
//! * **latency** (default `present_priority`): newest-wins. Each pump drains the reader to the
//! newest image (`acquireLatestImageAsync` drops the rest back to the pool) and presents it at
//! the next real vsync. Minimal depth.
//! * **smooth**: a small FIFO drained on each frame's [`CadenceClock`] due time — the source's own
//! cadence, recovered from the wire pts, finally with a truthful present clock beneath it.
//!
//! Memory safety does NOT rest on the release fences: an `AImage` (and the `AHardwareBuffer` it
//! wraps) stays alive through SurfaceFlinger's own reference taken by `setBuffer`, so deleting our
//! handle early at worst reuses a buffer a touch soon (a visible tear), never a use-after-free. The
//! fences are the correctness of *timing*, not of memory — which is what lets this ship behind an
//! auto-fallback with the residual risk being visual, not a crash.
use ndk::hardware_buffer::HardwareBuffer;
use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader};
use ndk::media::media_codec::MediaCodec;
use ndk::native_window::NativeWindow;
use punktfunk_core::phase::{CadenceClock, CadenceTuning, PanelGrid};
use std::collections::VecDeque;
use std::os::fd::OwnedFd;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::time::Instant;
use super::async_loop::DecodeEvent;
use super::latency::now_realtime_ns;
use super::presenter::PresentPriority;
use super::surface_control::{Layer, PresentComplete};
use super::vsync::now_monotonic_ns;
/// Reader pool depth. Must cover the codec's own in-flight outputs + the presenter's held candidate
/// / FIFO + the buffers still latched on SurfaceFlinger awaiting their release fence. Eight is
/// generous for a one-in-flight-ish presenter and small enough that no device balks.
const READER_MAX_IMAGES: i32 = 8;
/// SurfaceFlinger latch lead: a present targeted closer than this to a vsync is treated as missed
/// and the next grid point is used. Starts at 0 (the P2e on-glass finding — SF latched with no lead
/// on the NP3) and only ever grows if a device proves it needs more; kept simple here (fixed 0)
/// because the real-latch feedback makes the aggressive gamble self-correcting: a miss just presents
/// one vsync later, the same cost the predicted path always paid.
const LATCH_MARGIN_NS: i64 = 0;
/// Fallback panel period while none has been learned yet — one 120 Hz frame.
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
/// One image acquired from the reader, held until it is presented (or dropped as a newest-wins
/// eviction). Carries the decode stamps paired by pts for the latency metrics.
struct Acquired {
image: Image,
buffer: HardwareBuffer,
fence: Option<OwnedFd>,
pts_us: u64,
/// `CLOCK_REALTIME` decode-output stamp (for the skew-corrected end-to-end).
decoded_real: i128,
/// The source's due time on the cadence grid (`CLOCK_MONOTONIC`), `None` under latency.
due_ns: Option<i64>,
}
/// One image applied to SurfaceFlinger, awaiting its completion (metrics) and its successor's
/// completion (the release fence that frees it back to the pool).
struct Presented {
seq: u64,
image: Image,
pts_us: u64,
decoded_real: i128,
/// `CLOCK_REALTIME` / `CLOCK_MONOTONIC` instants the transaction was applied — the latch metric
/// pairs the completion's monotonic latch against `release_mono`, and rebases it onto realtime
/// via `release_real` for the skew-corrected end-to-end.
release_real: i128,
release_mono: i64,
}
/// The ASurfaceControl present backend.
pub(super) struct AscBackend {
reader: ImageReader,
/// Cached reader window handed to `MediaCodec::configure` as the decoder's output surface.
reader_window: NativeWindow,
layer: Layer,
/// `None` under latency; the source-cadence loop under smooth.
cadence: Option<CadenceClock>,
/// FIFO capacity: 0 = newest-wins (latency); 1..=3 = the smoothing store depth.
fifo_capacity: usize,
/// The negotiated source frame interval — the cadence cushion ceiling.
frame_interval_ns: i64,
/// Transactions applied but not yet completed — the real glass budget.
inflight: u32,
/// The pipeline depth the budget allows (2 = double-buffer; a shade more under smooth).
inflight_cap: u32,
// -- held images --
/// Latency: the newest acquired image not yet presented. Smooth leaves this `None`.
candidate: Option<Acquired>,
/// Smooth: images held for their due time, oldest first.
fifo: VecDeque<Acquired>,
/// Images on SurfaceFlinger, oldest first, awaiting release.
presented: VecDeque<Presented>,
// -- present clock --
/// The panel period learned from real latch spacings — a READOUT for the pf.present line only.
/// It must NOT drive the present target: the target produces the latch, so learning the period
/// from the latch and then targeting it locks the panel to whatever it first latched.
panel: PanelGrid,
/// The honest panel period from the mode table (`panel_hz`) — what the smooth grid snaps to.
/// Fixed for the session; the mode table is authoritative for the panel's fastest refresh.
panel_seed_ns: i64,
last_latch_ns: i64,
/// HDR `ADataSpace` for the transaction (`0` = SDR / leave default).
dataspace: i32,
/// Layer frame-rate vote (source Hz), applied once.
frame_rate: f32,
src_w: i32,
src_h: i32,
// -- bookkeeping --
next_seq: u64,
/// Decode stamps parked at `on_output`, keyed by the pts the codec echoes onto the buffer:
/// `(pts_us, decoded_real_ns, decoded_mono_ns)`.
stamps: VecDeque<(u64, i128, i64)>,
// -- 1 Hz pf.present window --
released: u64,
skipped: u64,
displays: u64,
forced: u64,
latch_us: Vec<u64>,
pace_us: Vec<u64>,
e2e_us: Vec<u64>,
last_flush: Instant,
}
impl AscBackend {
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
/// `dataspace` the HDR `ADataSpace` (`0` = SDR); `source_hz` the negotiated stream rate.
#[allow(clippy::too_many_arguments)]
pub(super) fn create(
window: &NativeWindow,
src_w: i32,
src_h: i32,
surface_w: i32,
surface_h: i32,
panel_hz: i32,
dataspace: i32,
source_hz: u32,
priority: PresentPriority,
) -> Option<AscBackend> {
let layer = Layer::create(window, surface_w, surface_h)?;
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
let reader = match ImageReader::new_with_usage(
src_w.max(1),
src_h.max(1),
ImageFormat::PRIVATE,
usage,
READER_MAX_IMAGES,
) {
Ok(r) => r,
Err(e) => {
log::warn!("asc: ImageReader init failed ({e:?}) — falling back to SurfaceView");
return None;
}
};
let reader_window = match reader.window() {
Ok(w) => w,
Err(e) => {
log::warn!("asc: ImageReader has no window ({e:?}) — falling back to SurfaceView");
return None;
}
};
let frame_interval_ns = match source_hz {
0 => FALLBACK_PERIOD_NS,
hz => 1_000_000_000 / i64::from(hz),
};
let (fifo_capacity, cadence, inflight_cap) = match priority {
PresentPriority::Latency => (0usize, None, 2u32),
PresentPriority::Smooth { buffer } => (
buffer,
Some(CadenceClock::new(CadenceTuning::snapping())),
(buffer as u32 + 1).clamp(2, 4),
),
};
log::info!(
"asc: backend up — {} ({}x{} @ {} Hz src, panel seed {} Hz, dataspace {:#x})",
match priority {
PresentPriority::Latency => "latency (newest-wins)".to_string(),
PresentPriority::Smooth { buffer } => format!("smooth (buffer {buffer})"),
},
src_w,
src_h,
source_hz,
panel_hz,
dataspace,
);
Some(AscBackend {
reader,
reader_window,
layer,
cadence,
fifo_capacity,
frame_interval_ns,
inflight: 0,
inflight_cap,
candidate: None,
fifo: VecDeque::new(),
presented: VecDeque::new(),
panel: PanelGrid::seeded(panel_hz),
panel_seed_ns: if panel_hz > 0 {
1_000_000_000 / panel_hz as i64
} else {
FALLBACK_PERIOD_NS
},
last_latch_ns: 0,
dataspace,
frame_rate: if source_hz > 0 { source_hz as f32 } else { 0.0 },
src_w: src_w.max(1),
src_h: src_h.max(1),
next_seq: 0,
stamps: VecDeque::new(),
released: 0,
skipped: 0,
displays: 0,
forced: 0,
latch_us: Vec::with_capacity(256),
pace_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
last_flush: Instant::now(),
})
}
/// The decoder output surface (the reader's window) for `MediaCodec::configure`.
pub(super) fn reader_window(&self) -> &NativeWindow {
&self.reader_window
}
/// Re-anchor the cadence loop on the next frame — the discontinuity hook the decode loop calls
/// when the re-anchor gate arms (a loss froze the picture and the decoder recovered behind it,
/// so the source→presentable delay the loop measured no longer holds). No-op under latency.
pub(super) fn reset_cadence(&mut self) {
if let Some(c) = self.cadence.as_mut() {
c.reset();
}
}
/// Route one decoded output buffer: render it into the reader when `present` (the re-anchor
/// gate approved it), else drop it off-glass. Parks the decode stamps for the pts the codec
/// echoes onto the buffer so `pump` can pair the latency metrics after acquire.
pub(super) fn on_output(
&mut self,
codec: &MediaCodec,
index: usize,
pts_us: u64,
decoded_real: i128,
decoded_mono: i64,
present: bool,
) {
if present {
self.stamps.push_back((pts_us, decoded_real, decoded_mono));
if self.stamps.len() > 128 {
self.stamps.pop_front();
}
}
if let Err(e) = codec.release_output_buffer_by_index(index, present) {
log::warn!("asc: release_output_buffer_by_index({index}, {present}): {e}");
}
}
/// Pop the decode stamps for `pts_us`, evicting older entries (decode order == input order).
fn take_stamp(&mut self, pts_us: u64) -> Option<(i128, i64)> {
while let Some(&(p, real, mono)) = self.stamps.front() {
if p > pts_us {
break;
}
self.stamps.pop_front();
if p == pts_us {
return Some((real, mono));
}
}
None
}
/// The desired present time for the frame being released, `CLOCK_MONOTONIC` (`0` = ASAP, only
/// used to bootstrap the phase before the first latch is known).
///
/// Both modes snap `not_before` up to an explicit panel-grid point: without one, applying two
/// transactions close together lets SurfaceFlinger coalesce the pair onto a single vsync and
/// idle the next — the on-glass 60-on-a-120-panel result of a plain ASAP present. Giving each
/// frame its own grid-spaced present time makes SF present them on consecutive vsyncs.
///
/// PERIOD is the mode-table seed (the honest panel maximum) — NEVER the latch-learned period,
/// or a slow latch would ratchet the target down and hold the panel at the lower rate. PHASE is
/// the last real latch. Latency passes `not_before = now + margin`; smooth additionally floors
/// it at the source due time.
fn next_present_target(&self, now_mono: i64, not_before: i64) -> i64 {
let period = self.panel_seed_ns;
if self.last_latch_ns <= 0 || period <= 0 {
return 0; // bootstrap: no phase yet — present ASAP to establish the first latch
}
let floor = not_before.max(now_mono);
let ahead = floor - self.last_latch_ns;
let k = ahead.div_euclid(period) + 1;
self.last_latch_ns + k.max(1) * period
}
/// Drain the reader into the held set (newest-wins candidate, or the smoothing FIFO), then
/// present the due frame if the budget is open. Returns `true` when a frame was applied.
pub(super) fn pump(
&mut self,
now_mono: i64,
stats: &crate::stats::VideoStats,
ev_tx: &mpsc::Sender<DecodeEvent>,
) -> bool {
self.drain_reader();
if self.inflight >= self.inflight_cap {
return false;
}
// Pick the frame to present.
let frame = if self.fifo_capacity == 0 {
self.candidate.take()
} else {
let reach = now_mono + LATCH_MARGIN_NS + self.panel_seed_ns;
match self.fifo.front() {
Some(f) if f.due_ns.is_none_or(|due| due <= reach) => self.fifo.pop_front(),
_ => return false,
}
};
let Some(mut frame) = frame else {
return false;
};
let not_before = frame.due_ns.map_or(now_mono + LATCH_MARGIN_NS, |d| {
d.max(now_mono + LATCH_MARGIN_NS)
});
let target = self.next_present_target(now_mono, not_before);
let seq = self.next_seq;
let applied = self.layer.present(
&frame.buffer,
self.src_w,
self.src_h,
frame.fence.take(),
target,
self.dataspace,
// The layer's fixed-source rate — applied once, at layer config (see `Layer::present`).
self.frame_rate,
seq,
ev_tx,
);
if !applied {
return false; // transaction failed; the image drops here, back to the pool
}
let release_real = now_realtime_ns();
let pace_us = ((release_real - frame.decoded_real).max(0) / 1000) as u64;
self.pace_us.push(pace_us);
stats.note_release(pace_us);
self.presented.push_back(Presented {
seq,
image: frame.image,
pts_us: frame.pts_us,
decoded_real: frame.decoded_real,
release_real,
release_mono: now_mono,
});
self.inflight += 1;
self.next_seq += 1;
self.released += 1;
true
}
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older are
/// dropped back to the pool by `acquireLatest`); smooth keeps order up to capacity.
fn drain_reader(&mut self) {
if self.fifo_capacity == 0 {
// Newest-wins: one acquire-latest collapses the whole burst to the freshest buffer.
if let Some(acq) = self.acquire(true) {
if self.candidate.replace(acq).is_some() {
self.skipped += 1; // an un-presented candidate was superseded
}
}
} else {
// Smooth: pull every ready image in order into the FIFO, evicting the oldest past cap.
while let Some(acq) = self.acquire(false) {
self.fifo.push_back(acq);
while self.fifo.len() > self.fifo_capacity {
self.fifo.pop_front();
self.skipped += 1;
}
}
}
}
/// Acquire one image (`latest` drops older, else FIFO) and pair its decode stamps + cadence due.
/// `None` when the reader is empty or a transient acquire error occurs.
fn acquire(&mut self, latest: bool) -> Option<Acquired> {
// SAFETY: we never touch the image's pixels — the acquire fence is handed straight to
// SurfaceFlinger via `setBuffer`, which is exactly the "await before access" the async
// acquire requires.
let res = unsafe {
if latest {
self.reader.acquire_latest_image_async()
} else {
self.reader.acquire_next_image_async()
}
};
let (image, fence) = match res {
Ok(AcquireResult::Image(pair)) => pair,
Ok(_) => return None, // no buffer available / max acquired
Err(e) => {
log::warn!("asc: acquire image failed: {e:?}");
return None;
}
};
let buffer = match image.hardware_buffer() {
Ok(b) => b,
Err(e) => {
log::warn!("asc: image has no hardware buffer: {e:?}");
return None; // `image` drops here → back to the pool
}
};
// The buffer timestamp is the pts the codec echoed (ns); pair the parked decode stamps.
let pts_ns = image.timestamp().unwrap_or(0).max(0);
let pts_us = (pts_ns / 1000) as u64;
let (decoded_real, decoded_mono) = self
.take_stamp(pts_us)
.unwrap_or((now_realtime_ns(), now_monotonic_ns()));
let due_ns = self.cadence.as_mut().map(|c| {
c.due_ns(
pts_us.saturating_mul(1000),
decoded_mono,
self.frame_interval_ns,
)
});
Some(Acquired {
image,
buffer,
fence,
pts_us,
decoded_real,
due_ns,
})
}
/// A completed transaction: reopen the budget, learn the panel period from the real latch,
/// record the latch + end-to-end, and free the buffer this frame replaced with its release
/// fence. Runs on the decode thread (the callback only forwarded the data).
pub(super) fn on_present_complete(
&mut self,
pc: PresentComplete,
clock_offset: i64,
stats: &crate::stats::VideoStats,
video_e2e: &AtomicU64,
) {
self.inflight = self.inflight.saturating_sub(1);
// Metrics for the frame that just latched (its own `seq`).
if pc.latch_ns > 0 {
if let Some(p) = self.presented.iter().find(|p| p.seq == pc.seq) {
let latch_ns = (pc.latch_ns - p.release_mono).clamp(0, 10_000_000_000);
let displayed_real = p.release_real + latch_ns as i128;
let e2e_ns = displayed_real + clock_offset as i128 - p.pts_us as i128 * 1000;
let latch_use = (latch_ns / 1000) as u64;
let display_use = ((displayed_real - p.decoded_real).max(0) / 1000) as u64;
self.latch_us.push(latch_use);
self.displays += 1;
if e2e_ns > 0 && e2e_ns < 10_000_000_000 {
let e2e_use = (e2e_ns / 1000) as u64;
self.e2e_us.push(e2e_use);
// Publish glass-to-glass RAW for the audio plane to align against.
video_e2e.store(e2e_ns as u64, Ordering::Relaxed);
stats.note_displayed(Some(e2e_use), Some(display_use), Some(latch_use));
} else {
stats.note_displayed(None, Some(display_use), Some(latch_use));
}
}
// Learn the true panel period from consecutive real latches.
if self.last_latch_ns > 0 {
self.panel.observe(pc.latch_ns - self.last_latch_ns);
}
self.last_latch_ns = pc.latch_ns;
}
// Retire every buffer this transaction replaced (seq < completed): the immediate
// predecessor gets the real release fence, any older straggler a plain delete (memory-safe
// — SurfaceFlinger holds its own reference until it is actually done).
let mut retired: Vec<Presented> = Vec::new();
while self.presented.front().is_some_and(|p| p.seq < pc.seq) {
retired.push(self.presented.pop_front().unwrap());
}
match (retired.pop(), pc.prev_release_fence) {
(Some(last), Some(fence)) => last.image.delete_async(fence),
(Some(last), None) => drop(last.image),
(None, Some(fence)) => drop(fence),
(None, None) => {}
}
// (`retired` now holds only older stragglers, dropped here — plain AImage_delete.)
drop(retired);
}
/// Publish the reader-drop count to the HUD and emit the 1 Hz `pf.present` mirror line. Called
/// once per loop pass; the `skipped` counter feeds the HUD each pass, the log line at 1 Hz.
pub(super) fn flush(&mut self, stats: &crate::stats::VideoStats) {
if self.skipped > 0 {
stats.note_skipped(std::mem::take(&mut self.skipped));
}
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
return;
}
self.last_flush = Instant::now();
if self.released == 0 && self.displays == 0 {
return; // idle
}
let (latch_p50, latch_max) = p50_max_ms(std::mem::take(&mut self.latch_us));
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
let (e2e_p50, e2e_max) = p50_max_ms(std::mem::take(&mut self.e2e_us));
// Under the smoothness intent, tail the source-cadence loop's health: `late‰` of all frames
// folded (a due time already past when the frame became presentable — the direct signal the
// cushion is too small, WP8's acceptance criterion), `jitter` (the loop residual's mean
// absolute deviation), `cushion`, and `reanchors`. Absent under latency (no loop). Counters
// are cumulative since the last re-anchor, so `late` reads as a rate over enough frames.
let cadence = self
.cadence
.as_ref()
.map(CadenceClock::health)
.map(|h| {
format!(
" late={}‰ jitterMs={:.2} cushionMs={:.2} reanchors={}",
h.late.saturating_mul(1000) / h.frames.max(1),
h.jitter_ns as f64 / 1e6,
h.cushion_ns as f64 / 1e6,
h.reanchors,
)
})
.unwrap_or_default();
log::info!(
target: "pf.present",
"asc released={} displays={} inflight={} qDepth={} paceMs p50={:.2} max={:.2} \
latchMs p50={:.2} max={:.2} e2eMs p50={:.2} max={:.2} panelMs={:.2} forced={}{}",
self.released,
self.displays,
self.inflight,
self.fifo.len(),
pace_p50,
pace_max,
latch_p50,
latch_max,
e2e_p50,
e2e_max,
self.panel.period_ns() as f64 / 1e6,
self.forced,
cadence,
);
self.released = 0;
self.displays = 0;
}
/// Teardown: drop every held image (candidate, FIFO, and still-presented) back to the pool
/// before the reader + codec go away. Plain deletes — SurfaceFlinger releases its own refs as
/// it finishes, so this is memory-safe without waiting on the fences.
pub(super) fn release_all(&mut self) {
self.candidate = None;
self.fifo.clear();
self.presented.clear();
}
}
impl AscBackend {
/// Update the HDR `ADataSpace` applied to every subsequent transaction (from the codec's
/// output format once it is known — the analogue of the SurfaceView path's
/// `apply_hdr_dataspace`). `0` leaves the surface SDR.
pub(super) fn set_dataspace(&mut self, dataspace: i32) {
if self.dataspace != dataspace {
self.dataspace = dataspace;
log::info!("asc: buffer dataspace now {dataspace:#x}");
}
}
}
/// Whether the ASurfaceControl backend is selected. Default ON; `debug.punktfunk.present_backend =
/// surfaceview` forces the legacy SurfaceView presenter (the field escape hatch, no rebuild). Any
/// other value — or an ASC init failure downstream — still lands on ASC-then-fallback.
pub(super) fn asc_backend_selected() -> bool {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.present_backend".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
!(n > 0 && &buf[..n as usize] == b"surfaceview")
}
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
if v.is_empty() {
return (0.0, 0.0);
}
v.sort_unstable();
(
v[v.len() / 2] as f64 / 1000.0,
*v.last().unwrap() as f64 / 1000.0,
)
}
+259 -60
View File
@@ -13,8 +13,10 @@ use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant};
use super::asc_presenter::{asc_backend_selected, AscBackend};
use super::display::{
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
apply_hdr_dataspace, hdr_dataspace, install_render_callback, release_render_callback,
DisplayTracker,
};
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
@@ -22,6 +24,7 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate,
};
use super::surface_control::PresentComplete;
use super::vsync::{now_monotonic_ns, VsyncClock};
use super::{
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
@@ -43,7 +46,7 @@ struct OutputReady {
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
enum DecodeEvent {
pub(super) enum DecodeEvent {
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
@@ -63,6 +66,10 @@ enum DecodeEvent {
FormatChanged,
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
Vsync,
/// An `ASurfaceControl` transaction completed (ASurfaceControl backend only): the real latch
/// time + the previous buffer's release fence, forwarded from the completion callback (a binder
/// thread) so the decode loop applies it on its own thread.
PresentComplete(super::surface_control::PresentComplete),
/// The codec reported an error; `fatal` when neither recoverable nor transient.
Error { fatal: bool },
}
@@ -89,6 +96,8 @@ pub(super) fn run_async(
present_priority,
smooth_buffer,
panel_hz,
surface_w,
surface_h,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -176,7 +185,40 @@ pub(super) fn run_async(
}
}
}
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
// Resolve the present intent once (shared by both backends).
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
// The present backend. ASurfaceControl (default) drives its own `AImageReader` output surface +
// compositor layer, scheduling against the panel's real present clock; the SurfaceView presenter
// below is the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview`
// sysprop. A non-null `asc` means the codec renders into the reader, not the SurfaceView window.
let mut asc = if asc_backend_selected() {
let initial_ds = if client.color.is_hdr() {
i32::from(ndk::data_space::DataSpace::Bt2020ItuPq)
} else {
0
};
AscBackend::create(
&window,
mode.width as i32,
mode.height as i32,
surface_w,
surface_h,
panel_hz,
initial_ds,
mode.refresh_hz,
priority,
)
} else {
log::info!("decode: present backend = SurfaceView (present_backend sysprop)");
None
};
// The decoder's output surface: the reader's window when ASC is active, else the SurfaceView.
let configure_window: &NativeWindow = asc.as_ref().map_or(&window, |a| a.reader_window());
if let Err(e) = codec.configure(
&format,
Some(configure_window),
MediaCodecDirection::Decoder,
) {
log::error!("decode: configure failed: {e}");
return;
}
@@ -190,8 +232,10 @@ pub(super) fn run_async(
mode.height
);
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
// off, every form factor gets the original soft seamless hint.
if mode.refresh_hz > 0
// off, every form factor gets the original soft seamless hint. ASC votes the rate on its own
// layer instead (the SurfaceView window shows nothing under the ASC path).
if asc.is_none()
&& mode.refresh_hz > 0
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
{
log::debug!(
@@ -205,6 +249,11 @@ pub(super) fn run_async(
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
// HUD is visible.
let clock_offset = client.clock_offset_shared();
// The shared cell the audio plane steers its jitter ring by — video is the master, and the
// present path is the only point that knows when a frame actually reached glass. Both backends
// publish into it (the ASC path from its transaction completions, the SurfaceView path from the
// OnFrameRendered tracker).
let video_e2e = client.video_e2e_shared();
// Whether the adaptive-bitrate controller wants the `decode` stage as its decoder-backlog
// signal (Automatic, non-PyroWave): then `in_flight` is fed regardless of the HUD.
let measure_decode = client.wants_decode_latency();
@@ -212,27 +261,30 @@ pub(super) fn run_async(
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
// parked in the tracker at release; the OnFrameRendered callback pairs it with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
// reclaimed after the codec is dropped below. SurfaceView backend only — the ASC path measures
// its display stage directly off the transaction completions.
let meter = Arc::new(PresentMeter::new());
// The tracker also publishes each confirmed present's end-to-end into the shared cell the audio
// plane steers its jitter ring by (`design/audio-latency-overhaul.md`) — video is the master,
// and this is the only point that knows when a frame actually reached glass.
let tracker = DisplayTracker::new(
stats.clone(),
clock_offset.clone(),
client.video_e2e_shared(),
video_e2e.clone(),
meter.clone(),
);
let render_cb = install_render_callback(&codec, &tracker);
let render_cb = if asc.is_none() {
install_render_callback(&codec, &tracker)
} else {
None
};
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
// legacy release-immediately path for a rebuild-free on-device A/B.
let mut presenter = if presenter_disabled_by_sysprop() {
// The SurfaceView timeline presenter (see `presenter.rs`): newest-wins / smoothing store,
// one-in-flight glass budget, timeline-timed release. `None` under the ASC backend, or when
// `debug.punktfunk.presenter = arrival` selects the legacy release-immediately path.
let mut presenter = if asc.is_some() {
None
} else if presenter_disabled_by_sysprop() {
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
None
} else {
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
log::info!(
"decode: presenter = timeline ({})",
match priority {
@@ -242,11 +294,15 @@ pub(super) fn run_async(
);
Some(Presenter::new(priority, mode.refresh_hz))
};
stats.set_presenter_active(presenter.is_some());
stats.set_presenter_active(presenter.is_some() || asc.is_some());
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
// the same event channel. The Sender parks here until that moment.
// the same event channel. The ASC backend derives its present clock from the real transaction
// latches instead, so it needs no choreographer.
let mut vsync: Option<VsyncClock> = None;
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
// A persistent Sender for the ASC path: the pump hands it to each transaction's completion
// callback, and it keeps the event channel alive for those callbacks.
let present_tx = asc.as_ref().map(|_| ev_tx.clone());
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
@@ -337,35 +393,52 @@ pub(super) fn run_async(
let mut fmt_dirty = false;
let mut vsync_tick = false;
let mut aus_dropped: u64 = 0;
// ASurfaceControl transaction completions coalesced into this pass, applied after the
// event drain (they run on the decode thread, not the binder thread that posted them).
let mut present_completes: Vec<PresentComplete> = Vec::new();
if let Some(ev) = ev0 {
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
if let DecodeEvent::PresentComplete(pc) = ev {
present_completes.push(pc);
} else {
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
}
// Coalesce every other event already queued into this one work pass — correct newest-only
// presentation across a decode burst, and batched feeding.
while let Ok(ev) = ev_rx.try_recv() {
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
if let DecodeEvent::PresentComplete(pc) = ev {
present_completes.push(pc);
} else {
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
}
if let Some(a) = asc.as_mut() {
let off = clock_offset.load(Ordering::Relaxed);
for pc in present_completes.drain(..) {
a.on_present_complete(pc, off, &stats, &video_e2e);
}
}
if vsync_tick {
if let Some(p) = presenter.as_mut() {
@@ -374,7 +447,12 @@ pub(super) fn run_async(
}
stats.note_skipped_overflow(aus_dropped); // parked-AU overflow: skips, flagged as such
if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
if let Some(a) = asc.as_mut() {
// ASC carries the HDR signal on the transaction, not the SurfaceView window.
a.set_dataspace(hdr_dataspace(&codec).map_or(0, i32::from));
} else {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
}
}
feed_ready(
&codec,
@@ -399,26 +477,48 @@ pub(super) fn run_async(
if let Some(p) = presenter.as_mut() {
p.reset_cadence();
}
if let Some(a) = asc.as_mut() {
a.reset_cadence();
}
}
let had_output = !ready.is_empty();
let rendered_before = rendered;
present_ready(
&codec,
&client,
measure_decode,
&mut ready,
&stats,
&in_flight,
&mut queued_stamps,
&meter,
clock_offset.load(Ordering::Relaxed),
&tracker,
&mut presenter,
&mut rendered,
&mut discarded,
&mut gate,
&mut recovery_flags,
);
if let Some(a) = asc.as_mut() {
// ASC path: fold the gate + record the decode-stage split (same as the SurfaceView
// path's measurement half), then render each approved output into the reader; the pump
// below composites it onto the layer.
asc_present_ready(
a,
&codec,
&client,
measure_decode,
&mut ready,
&stats,
&in_flight,
&mut queued_stamps,
clock_offset.load(Ordering::Relaxed),
&mut gate,
&mut recovery_flags,
);
} else {
present_ready(
&codec,
&client,
measure_decode,
&mut ready,
&stats,
&in_flight,
&mut queued_stamps,
&meter,
clock_offset.load(Ordering::Relaxed),
&tracker,
&mut presenter,
&mut rendered,
&mut discarded,
&mut gate,
&mut recovery_flags,
);
}
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
// even when the choreographer clock is absent.
@@ -475,6 +575,18 @@ pub(super) fn run_async(
}
}
}
// The ASurfaceControl backend's decision point — same "runs every pass" contract as the
// SurfaceView presenter, but its clock is the real transaction latches, so no choreographer
// is consulted. `present_tx` is the persistent Sender each transaction's completion callback
// rides back on.
if let Some(a) = asc.as_mut() {
if let Some(tx) = present_tx.as_ref() {
if a.pump(now_monotonic_ns(), &stats, tx) {
rendered += 1;
}
}
a.flush(&stats);
}
let presented_now = rendered > rendered_before;
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
@@ -584,6 +696,9 @@ pub(super) fn run_async(
if let Some(p) = presenter.as_mut() {
p.release_all(&codec); // hand every held output buffer back before the codec stops
}
if let Some(a) = asc.as_mut() {
a.release_all(); // drop every held image back to the reader pool before it goes away
}
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
let _ = codec.stop();
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
@@ -591,6 +706,10 @@ pub(super) fn run_async(
let _ = j.join();
}
drop(codec); // AMediaCodec_delete — after this no render callback can fire
// The ASC layer + reader outlive the codec (which rendered into the reader's window); dropping
// now releases the reader and decrements the compositor control's refcount — the control itself
// is freed only once every in-flight completion callback has also dropped its share.
drop(asc);
if let Some(ud) = render_cb {
// SAFETY: the codec was dropped above; this registration's single reclaim.
unsafe { release_render_callback(ud) };
@@ -777,6 +896,9 @@ fn dispatch_event(
gate.arm(Instant::now());
}
}
// Intercepted by the caller before it ever reaches here (routed to the ASC backend on the
// decode thread); this arm keeps the match exhaustive.
DecodeEvent::PresentComplete(_) => {}
}
false
}
@@ -1059,3 +1181,80 @@ fn present_ready(
}
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
}
/// The ASurfaceControl backend's analogue of [`present_ready`]: record the same decode-stage split
/// (the HUD histogram + the ABR decoder-backlog signal), then fold each decoded output through the
/// re-anchor gate and render it into the reader (`present = true`) or drop it off-glass. The pump
/// composites the rendered images onto the layer; the display stage is measured there from the real
/// transaction latches, not here. `ready` is drained.
#[allow(clippy::too_many_arguments)] // one call site; mirrors `present_ready`'s measurement half
fn asc_present_ready(
asc: &mut AscBackend,
codec: &MediaCodec,
client: &NativeClient,
measure_decode: bool,
ready: &mut Vec<OutputReady>,
stats: &crate::stats::VideoStats,
in_flight: &Mutex<VecDeque<(u64, i128)>>,
queued_stamps: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
gate: &mut ReanchorGate,
recovery_flags: &mut VecDeque<(u64, u32)>,
) {
if ready.is_empty() {
return;
}
// Decode-stage measurement (identical to the SurfaceView path's first block, minus the
// PresentMeter — the ASC backend keeps its own 1 Hz line). Pairs each output's receipt +
// queued stamps for the `decode` histogram, the feed/codec split, and the ABR signal.
{
let want_stage = stats.enabled() || measure_decode;
let mut g = in_flight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for o in ready.iter() {
let received_ns = if want_stage {
note_decoded_pts(
client,
measure_decode,
stats,
&mut g,
clock_offset,
o.pts_us,
o.decoded_ns,
)
} else {
None
};
let queued = take_stamp(queued_stamps, o.pts_us);
let codec_us = queued.map(|q| ((o.decoded_ns - q).max(0) / 1000) as u64);
if let Some(c) = codec_us {
let feed_us = match (queued, received_ns) {
(Some(q), Some(r)) => Some(((q - r).max(0) / 1000) as u64),
_ => None,
};
stats.note_decode_split(feed_us, c);
}
}
}
// Fold every output through the gate in pts (== decode) order — a `false` verdict is withheld
// concealment (dropped off-glass, the ASC equivalent of the SurfaceView release-unrendered).
let now = Instant::now();
let mut withheld: u64 = 0;
for o in ready.drain(..) {
let flags = take_flags(recovery_flags, o.pts_us);
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
if !present {
withheld += 1;
}
asc.on_output(
codec,
o.index,
o.pts_us,
o.decoded_ns,
o.decoded_mono_ns,
present,
);
}
stats.note_skipped(withheld); // gate-withheld frames (the reader-drop skips ride `asc.flush`)
}
+18 -3
View File
@@ -1,16 +1,25 @@
//! Android video decode (android-only): pull HEVC access units from the connector and render them
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
//! Android video decode (android-only): pull HEVC access units from the connector into NDK
//! `AMediaCodec` — hardware decode, zero per-frame JNI.
//!
//! The decoded frames reach glass through one of two present backends (see [`asc_presenter`] and
//! [`presenter`]). The default is the **ASurfaceControl** backend: the codec renders into an
//! `AImageReader` and each frame is composited onto an `ASurfaceControl` layer via a transaction
//! carrying a desired present time, scheduling against the panel's real present clock. The
//! **SurfaceView** presenter — `releaseOutputBufferAtTime` straight to the SurfaceView's window — is
//! the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview` sysprop.
//!
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
//! the codec + surface for its whole life; [`crate::session`] signals it to stop via the shared flag.
mod asc_presenter;
mod async_loop;
mod display;
mod latency;
mod presenter;
mod setup;
mod surface_control;
mod sync_loop;
mod vsync;
@@ -124,6 +133,12 @@ pub(crate) struct DecodeOptions {
/// 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,
/// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from
/// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this
/// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin
/// couldn't read it yet, and the backend falls back to the window buffer size.
pub surface_w: i32,
pub surface_h: i32,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
@@ -0,0 +1,415 @@
//! The `ASurfaceControl` compositor layer behind the ASurfaceControl presenter backend.
//!
//! This is the Android analogue of what the Apple client gets from `CAMetalDisplayLink` +
//! `preferredFrameLatency = 1`: a present path that schedules each frame against the panel's own
//! timeline and hands back the *real* present feedback, instead of the MediaCodec→SurfaceView→
//! BufferQueue path that predicts the latch and hopes the `OnFrameRendered` callbacks arrive.
//!
//! A `Layer` owns one `ASurfaceControl` created as a child of the SurfaceView's `ANativeWindow`;
//! the decoder renders into an `AImageReader` and the
//! presenter composites each acquired `AHardwareBuffer` onto this layer via an `ASurfaceTransaction`
//! that carries a desired present time (the single actuator both present modes drive) and an
//! acquire fence. Every applied transaction registers a one-shot completion callback that reports
//! the frame's real latch time and the *previous* buffer's release fence back through the decode
//! loop's event channel — the truthful present clock the cadence loop and the glass budget were
//! missing.
//!
//! Every `ASurface*` entry point is **API 29** — above the crate's minSdk-28 floor — so all are
//! `dlsym`-resolved from `libandroid.so`, exactly as [`crate::adpf`] and [`super::vsync`] resolve
//! their own >-floor symbols; a hard import of any of them would make `System.loadLibrary` fail on
//! every API-28 device even where this backend is never selected. Absent (or a null layer) ⇒
//! [`Layer::create`] returns `None` and the caller falls back to the SurfaceView presenter.
use ndk::hardware_buffer::HardwareBuffer;
use ndk::native_window::NativeWindow;
use std::ffi::c_void;
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
use std::sync::{mpsc, Arc};
use super::async_loop::DecodeEvent;
// ---- Opaque native types (not in `ndk-sys 0.6`) ------------------------------------------------
#[repr(C)]
struct ASurfaceControl {
_p: [u8; 0],
}
#[repr(C)]
struct ASurfaceTransaction {
_p: [u8; 0],
}
#[repr(C)]
struct ASurfaceTransactionStats {
_p: [u8; 0],
}
/// `ARect` — the `setGeometry` source/destination rectangle (`android/native_window.h`).
#[repr(C)]
#[derive(Clone, Copy)]
struct ARect {
left: i32,
top: i32,
right: i32,
bottom: i32,
}
/// `ANATIVEWINDOW_TRANSFORM_IDENTITY` — no rotation/flip; the decoder already emits upright frames.
const TRANSFORM_IDENTITY: i32 = 0;
/// `ASURFACE_TRANSACTION_VISIBILITY_SHOW`.
const VISIBILITY_SHOW: i8 = 1;
// ---- The `dlsym`-resolved entry-point table ----------------------------------------------------
type CreateFromWindowFn = unsafe extern "C" fn(
*mut ndk_sys::ANativeWindow,
*const std::ffi::c_char,
) -> *mut ASurfaceControl;
type AcReleaseFn = unsafe extern "C" fn(*mut ASurfaceControl);
type TxnCreateFn = unsafe extern "C" fn() -> *mut ASurfaceTransaction;
type TxnDeleteFn = unsafe extern "C" fn(*mut ASurfaceTransaction);
type TxnApplyFn = unsafe extern "C" fn(*mut ASurfaceTransaction);
type TxnSetBufferFn = unsafe extern "C" fn(
*mut ASurfaceTransaction,
*mut ASurfaceControl,
*mut ndk_sys::AHardwareBuffer,
RawFd,
);
type TxnSetVisibilityFn = unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, i8);
type TxnSetZOrderFn = unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, i32);
type TxnSetGeometryFn = unsafe extern "C" fn(
*mut ASurfaceTransaction,
*mut ASurfaceControl,
*const ARect,
*const ARect,
i32,
);
type TxnSetDesiredPresentTimeFn = unsafe extern "C" fn(*mut ASurfaceTransaction, i64);
type TxnSetBufferDataSpaceFn =
unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, i32);
type TxnSetFrameRateFn =
unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, f32, i8);
type OnCompleteCb = unsafe extern "C" fn(*mut c_void, *mut ASurfaceTransactionStats);
type TxnSetOnCompleteFn = unsafe extern "C" fn(*mut ASurfaceTransaction, *mut c_void, OnCompleteCb);
type StatsGetLatchTimeFn = unsafe extern "C" fn(*mut ASurfaceTransactionStats) -> i64;
type StatsGetPrevReleaseFenceFn =
unsafe extern "C" fn(*mut ASurfaceTransactionStats, *mut ASurfaceControl) -> RawFd;
struct Api {
create_from_window: CreateFromWindowFn,
ac_release: AcReleaseFn,
txn_create: TxnCreateFn,
txn_delete: TxnDeleteFn,
txn_apply: TxnApplyFn,
txn_set_buffer: TxnSetBufferFn,
txn_set_visibility: TxnSetVisibilityFn,
txn_set_z_order: TxnSetZOrderFn,
txn_set_geometry: TxnSetGeometryFn,
txn_set_present_time: TxnSetDesiredPresentTimeFn,
/// `setBufferDataSpace` is present from API 29 in practice but historically under-declared —
/// resolved optionally, so an SDR stream (which never touches it) works even where it is absent.
txn_set_dataspace: Option<TxnSetBufferDataSpaceFn>,
/// `setFrameRate` is **API 30** — optional, `None` on API 29.
txn_set_frame_rate: Option<TxnSetFrameRateFn>,
txn_set_on_complete: TxnSetOnCompleteFn,
stats_latch_time: StatsGetLatchTimeFn,
stats_prev_release_fence: StatsGetPrevReleaseFenceFn,
}
impl Api {
/// Resolve the whole `ASurface*` table from `libandroid.so`, or `None` on API < 29 (any required
/// symbol absent). The two optional entries (`setBufferDataSpace`, `setFrameRate`) do not gate.
fn resolve() -> Option<Api> {
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never
// closed — a process-lifetime handle). Each `dlsym` returns null when the symbol is absent
// (device below API 29), checked before transmuting the non-null pointer to its fn type.
unsafe {
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() {
return None;
}
let req = |name: &std::ffi::CStr| -> Option<*mut c_void> {
let p = libc::dlsym(lib, name.as_ptr());
(!p.is_null()).then_some(p)
};
Some(Api {
create_from_window: std::mem::transmute::<*mut c_void, CreateFromWindowFn>(req(
c"ASurfaceControl_createFromWindow",
)?),
ac_release: std::mem::transmute::<*mut c_void, AcReleaseFn>(req(
c"ASurfaceControl_release",
)?),
txn_create: std::mem::transmute::<*mut c_void, TxnCreateFn>(req(
c"ASurfaceTransaction_create",
)?),
txn_delete: std::mem::transmute::<*mut c_void, TxnDeleteFn>(req(
c"ASurfaceTransaction_delete",
)?),
txn_apply: std::mem::transmute::<*mut c_void, TxnApplyFn>(req(
c"ASurfaceTransaction_apply",
)?),
txn_set_buffer: std::mem::transmute::<*mut c_void, TxnSetBufferFn>(req(
c"ASurfaceTransaction_setBuffer",
)?),
txn_set_visibility: std::mem::transmute::<*mut c_void, TxnSetVisibilityFn>(req(
c"ASurfaceTransaction_setVisibility",
)?),
txn_set_z_order: std::mem::transmute::<*mut c_void, TxnSetZOrderFn>(req(
c"ASurfaceTransaction_setZOrder",
)?),
txn_set_geometry: std::mem::transmute::<*mut c_void, TxnSetGeometryFn>(req(
c"ASurfaceTransaction_setGeometry",
)?),
txn_set_present_time: std::mem::transmute::<*mut c_void, TxnSetDesiredPresentTimeFn>(
req(c"ASurfaceTransaction_setDesiredPresentTime")?,
),
txn_set_dataspace: req(c"ASurfaceTransaction_setBufferDataSpace")
.map(|p| std::mem::transmute::<*mut c_void, TxnSetBufferDataSpaceFn>(p)),
txn_set_frame_rate: req(c"ASurfaceTransaction_setFrameRate")
.map(|p| std::mem::transmute::<*mut c_void, TxnSetFrameRateFn>(p)),
txn_set_on_complete: std::mem::transmute::<*mut c_void, TxnSetOnCompleteFn>(req(
c"ASurfaceTransaction_setOnComplete",
)?),
stats_latch_time: std::mem::transmute::<*mut c_void, StatsGetLatchTimeFn>(req(
c"ASurfaceTransactionStats_getLatchTime",
)?),
stats_prev_release_fence: std::mem::transmute::<
*mut c_void,
StatsGetPrevReleaseFenceFn,
>(req(
c"ASurfaceTransactionStats_getPreviousReleaseFenceFd",
)?),
})
}
}
}
/// The `ASurfaceControl` handle, reference-counted so it outlives every in-flight transaction. The
/// layer holds one `Arc`; each pending completion callback's context holds another. `release` is
/// called exactly once — when the layer is dropped AND the last outstanding callback has fired — so
/// a completion that lands after teardown never indexes a freed control (the render-callback
/// reclaim hazard, in the transaction world).
struct ScHandle {
sc: *mut ASurfaceControl,
release: AcReleaseFn,
}
// SAFETY: `sc` is only ever passed back to `ASurface*` C entry points (never dereferenced in Rust),
// and its release is serialised by the `Arc` refcount reaching zero on whichever thread drops last.
unsafe impl Send for ScHandle {}
// SAFETY: as above — the raw handle is opaque to Rust and only handed to the thread-safe `ASurface*`
// C API; shared read access across threads (the completion callback) never mutates it.
unsafe impl Sync for ScHandle {}
impl Drop for ScHandle {
fn drop(&mut self) {
// SAFETY: created by `createFromWindow`; the `Arc` guarantees this is the sole, final release
// and that no transaction or callback still references `sc`.
unsafe { (self.release)(self.sc) };
}
}
/// One presented transaction's real feedback, posted from the completion callback (a binder thread)
/// into the decode loop's event channel. The loop matches `seq` to the buffer it retired and frees
/// it once `prev_release_fence` signals.
pub(super) struct PresentComplete {
/// The presenter's monotonically increasing submit sequence for this transaction.
pub seq: u64,
/// SurfaceFlinger's latch instant for this frame (`CLOCK_MONOTONIC` ns) — the truthful present
/// clock: consecutive latches are one true panel period apart, and `latch release` is the
/// real `latch` stat, both of which the predicted path could only guess at.
pub latch_ns: i64,
/// The release fence for the buffer this transaction REPLACED (the previous frame on the
/// layer), or `None` when the platform reports none. The loop deletes that buffer's image with
/// this fence so it is returned to the reader's pool only once SurfaceFlinger is done with it.
pub prev_release_fence: Option<OwnedFd>,
}
/// The completion callback's per-transaction context, leaked as a raw pointer into
/// `setOnComplete` and reclaimed inside the callback (which fires exactly once per applied
/// transaction). Carries only `Send` data so the binder-thread callback is sound.
struct CompleteCtx {
tx: mpsc::Sender<DecodeEvent>,
seq: u64,
/// A shared reference to the layer's `ASurfaceControl`, needed to read the per-surface release
/// fence out of the stats. Holding the `Arc` keeps the control alive for the callback even if
/// the layer was already dropped.
sc: Arc<ScHandle>,
prev_fence_fn: StatsGetPrevReleaseFenceFn,
latch_fn: StatsGetLatchTimeFn,
}
/// The `ASurfaceTransaction_OnComplete` trampoline (a binder thread). Reclaims its leaked context,
/// reads the real latch time + the previous buffer's release fence, and forwards them to the decode
/// loop. Panic-free by construction (an unwind out of an `extern "C"` fn would abort the process).
unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTransactionStats) {
if context.is_null() {
return;
}
// SAFETY: `context` is the `Box<CompleteCtx>` leaked in `Layer::present`; the platform delivers
// it exactly once per applied transaction, so this single reclaim is correct.
let ctx = unsafe { Box::from_raw(context as *mut CompleteCtx) };
let latch_ns = if stats.is_null() {
0
} else {
// SAFETY: `stats` is valid for the duration of this callback (platform contract).
unsafe { (ctx.latch_fn)(stats) }
};
let prev_release_fence = if stats.is_null() {
None
} else {
// SAFETY: valid stats + the layer's live `ASurfaceControl`; a returned fd is owned by us
// and closed via `OwnedFd`. `-1` means no fence.
let fd = unsafe { (ctx.prev_fence_fn)(stats, ctx.sc.sc) };
// SAFETY: a non-negative fd returned by `getPreviousReleaseFenceFd` is a fresh owned fence
// descriptor whose ownership the API transfers to us; wrapping it in `OwnedFd` closes it.
(fd >= 0).then(|| unsafe { OwnedFd::from_raw_fd(fd) })
};
let _ = ctx.tx.send(DecodeEvent::PresentComplete(PresentComplete {
seq: ctx.seq,
latch_ns,
prev_release_fence,
}));
}
/// One `ASurfaceControl` layer, a child of the SurfaceView's window, that the presenter composites
/// decoded buffers onto. Owns nothing thread-shared; lives on and is dropped by the decode loop.
pub(super) struct Layer {
api: Api,
sc: Arc<ScHandle>,
/// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it.
dest_w: i32,
dest_h: i32,
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
configured: bool,
}
impl Layer {
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
///
/// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child
/// layer is composited into, which is the display footprint of the (aspect-fitted) video view,
/// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a
/// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank
/// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view
/// yet) falls back to that buffer size as the best remaining guess.
pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option<Layer> {
let api = Api::resolve()?;
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
// static NUL-terminated string; the call returns null on failure (checked).
let sc =
unsafe { (api.create_from_window)(window.ptr().as_ptr(), c"punktfunk-video".as_ptr()) };
if sc.is_null() {
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
return None;
}
let dest_w = if dest_w > 0 {
dest_w
} else {
window.width().max(1)
};
let dest_h = if dest_h > 0 {
dest_h
} else {
window.height().max(1)
};
log::info!(
"asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})",
window.width(),
window.height(),
);
Some(Layer {
sc: Arc::new(ScHandle {
sc,
release: api.ac_release,
}),
api,
dest_w,
dest_h,
configured: false,
})
}
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
/// tagged with `seq`. `dataspace` is the HDR `ADataSpace` value (`0` = leave default/SDR).
/// `frame_rate` votes the layer's rate once (`0.0` skips). Returns `false` if the transaction
/// could not be created (the caller then frees the buffer itself).
#[allow(clippy::too_many_arguments)]
pub(super) fn present(
&mut self,
buffer: &HardwareBuffer,
src_w: i32,
src_h: i32,
acquire_fence: Option<OwnedFd>,
desired_present_ns: i64,
dataspace: i32,
frame_rate: f32,
seq: u64,
ev_tx: &mpsc::Sender<DecodeEvent>,
) -> bool {
// SAFETY: `txn_create` returns a fresh transaction or null; every setter below takes that
// transaction + this layer's live `sc` + valid arguments; `apply`/`delete` consume it once.
unsafe {
let txn = (self.api.txn_create)();
if txn.is_null() {
// The acquire fence would leak if we returned without consuming it.
drop(acquire_fence);
return false;
}
let sc = self.sc.sc;
let fence_fd = acquire_fence
.map(std::os::fd::IntoRawFd::into_raw_fd)
.unwrap_or(-1);
(self.api.txn_set_buffer)(txn, sc, buffer.as_ptr(), fence_fd);
let src = ARect {
left: 0,
top: 0,
right: src_w.max(1),
bottom: src_h.max(1),
};
let dst = ARect {
left: 0,
top: 0,
right: self.dest_w,
bottom: self.dest_h,
};
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
if dataspace != 0 {
if let Some(f) = self.api.txn_set_dataspace {
f(txn, sc, dataspace);
}
}
if !self.configured {
(self.api.txn_set_visibility)(txn, sc, VISIBILITY_SHOW);
(self.api.txn_set_z_order)(txn, sc, 0);
// Declare the layer as fixed-rate video at the source rate (compatibility 1 =
// FIXED_SOURCE) so a compliant display aligns its refresh to it. Best-effort: an
// LTPO governor may still run "video" content below its own floor for power (the
// NP3 does — no app-side rate hint raises its render-range floor; the display's
// Minimum-refresh-rate system setting is the only lever there).
if frame_rate > 0.0 {
if let Some(f) = self.api.txn_set_frame_rate {
f(txn, sc, frame_rate, 1);
}
}
self.configured = true;
}
(self.api.txn_set_present_time)(txn, desired_present_ns);
// One-shot completion context, reclaimed inside the callback. The `Arc` clone keeps the
// control alive for the callback even past the layer's own drop.
let ctx = Box::into_raw(Box::new(CompleteCtx {
tx: ev_tx.clone(),
seq,
sc: self.sc.clone(),
prev_fence_fn: self.api.stats_prev_release_fence,
latch_fn: self.api.stats_latch_time,
}));
(self.api.txn_set_on_complete)(txn, ctx as *mut c_void, on_complete);
(self.api.txn_apply)(txn);
(self.api.txn_delete)(txn);
}
true
}
}
@@ -48,6 +48,10 @@ pub(super) fn run_sync(
present_priority: _,
smooth_buffer: _,
panel_hz: _,
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
// SurfaceView, so it never needs the view's on-screen size.
surface_w: _,
surface_h: _,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -30,6 +30,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
present_priority: jni::sys::jint,
smooth_buffer: jni::sys::jint,
panel_fps: jni::sys::jint,
surface_w: jni::sys::jint,
surface_h: jni::sys::jint,
) {
use super::VideoThread;
use std::sync::atomic::AtomicBool;
@@ -78,6 +80,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
present_priority,
smooth_buffer,
panel_hz: panel_fps,
surface_w,
surface_h,
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
+4 -19
View File
@@ -148,28 +148,14 @@ mod imp {
/// place used to be the design ("reverts at process exit") — but the host is a 24/7 service,
/// so after one stream it competed at HIGH class with a 1 ms global timer against whatever
/// the user played locally, forever.
///
/// 🛑 **Nothing in here may log, or touch anything that logs.** This runs from
/// [`HotThreadGuard`]'s `Drop`, which is a **TLS destructor** — and by then this thread's
/// *other* thread-locals may already be gone, including the ones `tracing_subscriber`'s
/// registry keeps (it is `sharded-slab`-backed, and the slab's per-thread registration is a
/// `thread_local!` read with `LocalKey::with`). Emitting an event here panicked with "cannot
/// access a Thread Local Storage value during or after destruction", and **a panic that
/// escapes a TLS destructor is fatal in Rust** — `fatal runtime error: thread local panicked
/// on drop, aborting`. So one `info!` line killed the whole host on session teardown and the
/// SCM restarted it ~6 s later, which read in the field as a mystery reconnect (on glass,
/// .173: four aborts, every one of them a session teardown).
///
/// The revert itself is only FFI and stays here, inside the refcount lock, so it remains
/// atomic against a session starting concurrently. The counterpart "applied" line in
/// [`tune_process`] runs on a live thread and is kept — that one is safe.
fn untune_process() {
// SAFETY: same FFI surface as `tune_process` — plain-integer arguments, constant
// pseudo-handle, no pointers or buffers. Sound in a TLS destructor: no Rust TLS is read.
// pseudo-handle, no pointers or buffers.
unsafe {
timeEndPeriod(1); // pairs the timeBeginPeriod(1)
DwmEnableMMCSS(0);
SetPriorityClass(GetCurrentProcess(), NORMAL_PRIORITY_CLASS);
tracing::info!("windows session tuning reverted (timer, DWM MMCSS, NORMAL priority)");
}
}
@@ -179,9 +165,8 @@ mod imp {
impl Drop for HotThreadGuard {
fn drop(&mut self) {
// ⚠ TLS DESTRUCTOR. Everything reached from here must be panic-free and must not log —
// see [`untune_process`] for what a single `info!` here cost. A poisoned lock skips the
// revert (best-effort, like every call here) rather than panicking.
// A poisoned lock skips the revert (best-effort, like every call here) instead of
// panicking inside a TLS destructor.
if let Ok(mut n) = HOT_THREADS.lock() {
*n -= 1;
if *n == 0 {
+12 -97
View File
@@ -574,10 +574,6 @@ fn watch(
"the launch command exited immediately (a launcher handing off) and this \
title has no detect signals stopping game tracking for it"
);
// Nothing will ever observe this game again, so say that rather than leave the
// console on "launching" forever — the same honest answer `open` reaches when it
// starts no watcher at all.
shared.set_state(GameState::Untracked);
return;
}
tracing::debug!(
@@ -622,7 +618,6 @@ fn watch(
LeaseKind::Matched
};
if matches!(kind, LeaseKind::Untracked) {
shared.set_state(GameState::Untracked);
return;
}
} else {
@@ -648,7 +643,6 @@ fn watch(
LeaseKind::Matched
};
if matches!(kind, LeaseKind::Untracked) {
shared.set_state(GameState::Untracked);
return;
}
}
@@ -660,25 +654,17 @@ fn watch(
// detect signals is still fully tracked.
//
// But a launcher that is about to hand off and exit looks *exactly* like the game for its
// first few seconds, so wait out the shim window before believing this child is it —
// otherwise the lease leaves this phase on its very first poll, the reclassification above
// never gets to run, and the hand-off that follows is read as the game exiting. On Linux
// that ended a session ~7 s after launching any Steam title, before the game had even
// started (on glass, .41).
// first few seconds. When the store gave us signals to recognize the real game by, wait out
// the shim window before believing this child is it — otherwise the lease leaves this phase
// on its very first poll, the reclassification above never gets to run, and the hand-off
// that follows is read as the game exiting. On Linux that ended a session ~7 s after
// launching any Steam title, before the game had even started (on glass, .41).
//
// ⚠ This used to be skipped whenever the title had **no** detect signals, on the reasoning
// that the child was then all we had — which quietly made the no-signals case the one shape
// the shim window could not protect. It is the shape that needs it most: a hint-less title
// is exactly the one whose launch is a bare protocol hand-off, and `spec.is_empty()` is
// *fewer* reasons to trust the child, not more. On Windows every launch recipe is a
// hand-off by construction (`explorer.exe "playnite://…"`, `Steam.exe "steam://…"`), so
// carrying its pid (0.30) made a hint-less title report `running` on its first poll and
// `exited` a second later, when the forwarder quit — ending the session and dropping the
// stream while the game was still starting. Both callers of the pid path already documented
// this window as their protection; now they have it.
// With no signals the child is all we have, so it still counts immediately: a custom command
// is tracked exactly as before.
let child_alive = matches!(kind, LeaseKind::Child)
&& (child.is_some() || spawned.is_some())
&& spawned_at.elapsed() >= SHIM_WINDOW;
&& (shared.spec.is_empty() || spawned_at.elapsed() >= SHIM_WINDOW);
let live = scanner.find(&shared.spec, shared.launch_stamp);
if !live.is_empty() || child_alive {
known = live.clone();
@@ -1758,16 +1744,10 @@ mod tests {
/// The same launch, driven to its exit: the pid dying is the game exiting, and that fires the
/// action that ends the session — which is precisely what never happened in the field report.
///
/// ⚠ The process must outlive [`SHIM_WINDOW`] for that reading to be the right one. It used to
/// be a 4-second `sleep`, which is *inside* the window — the test passed only because a lease
/// with no detect signals skipped the window entirely, which is the bug the sibling test below
/// pins. Keep this fixture longer than the window: a launch that quits sooner is a hand-off, and
/// treating it as a game exit is what dropped the stream a second after every Windows launch.
///
/// Ignored by default: it outlives the shim window and then waits out [`EXIT_CONFIRM`], ~12 s.
/// Ignored by default: it waits out [`EXIT_CONFIRM`] after a real process ends, ~10 s.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "drives a real process for ~12s (shim window + exit confirmation)"]
#[ignore = "drives a real process for ~10s (exit confirmation)"]
fn a_pid_only_launch_reports_its_exit() {
use std::sync::atomic::AtomicUsize;
@@ -1775,7 +1755,7 @@ mod tests {
// `/proc/<pid>` entry with an unchanged start time — so the scan would call it alive
// forever and the exit under test could never be observed.
let mut child = std::process::Command::new("sleep")
.arg("8")
.arg("4")
.spawn()
.expect("spawn the fake game");
let pid = child.id();
@@ -1798,7 +1778,7 @@ mod tests {
);
let shared = lease.shared();
let deadline = Instant::now() + Duration::from_secs(30);
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline && shared.state() != GameState::Exited {
std::thread::sleep(Duration::from_millis(250));
}
@@ -1810,71 +1790,6 @@ mod tests {
);
}
/// 🛑 The 2026-08-18 field report, in one test: a Windows launch is a **protocol hand-off**, and
/// a hand-off must never be mistaken for the game exiting.
///
/// The shape is `explorer.exe "playnite://playnite/start/<id>"` — the host spawns a forwarder,
/// gets its pid, and the forwarder quits about a second later having handed the launch to
/// Playnite. The title carries no detect hint (the Playnite plugin only sends `install_dir` when
/// Playnite knows one), so the lease has the pid and nothing else.
///
/// What shipped in 0.30 did this: the empty spec skipped [`SHIM_WINDOW`], so the lease called
/// the forwarder "the game running" on its first poll, and a second later called the
/// forwarder's exit "the game exited" — closing the connection with `APP_EXITED`. The player
/// saw the game start on the host and the stream drop, with the console reporting no running
/// game. Two things have to hold for that not to happen, and both are asserted here.
///
/// Ignored by default: it must outlive [`SHIM_WINDOW`] and [`EXIT_CONFIRM`] to prove the
/// session is not ended *later* either.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "drives a real process for ~10s (shim window + exit confirmation)"]
fn a_pid_only_handoff_with_no_signals_never_ends_the_session() {
use std::sync::atomic::AtomicUsize;
// Reaped on its own thread — see the sibling test: a zombie keeps its `/proc` entry and
// would read as alive forever.
let mut child = std::process::Command::new("sleep")
.arg("1")
.spawn()
.expect("spawn the fake forwarder");
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
static HANDOFF_EXITS: AtomicUsize = AtomicUsize::new(0);
HANDOFF_EXITS.store(0, Ordering::SeqCst);
let lease = open(
LeaseRequest {
spawned: Some(pid),
spec: DetectSpec::default(),
launch_stamp: launch_clock(),
..req("playnite:handoff", DetectSpec::default(), false)
},
Box::new(|| {
HANDOFF_EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
let shared = lease.shared();
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(2));
assert_eq!(
HANDOFF_EXITS.load(Ordering::SeqCst),
0,
"a launch command handing off must not end the session — this is the field report"
);
// ...and the console must not be told the game is up either. `Untracked` is the honest
// answer: nothing is watching this title, so nothing will ever report it starting or
// stopping. Sitting at `Launching` (or claiming `Running`) are the two lies 0.30 set out
// to remove, and giving up on tracking must not quietly reinstate one of them.
assert_eq!(
shared.state(),
GameState::Untracked,
"nothing is watching this title any more, and the row has to say so"
);
}
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
/// notices when it exits, and reports that exit exactly once.
///
@@ -332,8 +332,7 @@ fn run(
#[allow(unused_mut)]
let mut spawned_now = false;
// Windows hands back a pid rather than a child; kept for the lease (see the native plane
// and `gamelease::LeaseRequest::spawned`). `None` elsewhere, when nothing was spawned, and
// when what was spawned only forwards the launch (`library::WinRecipe::owns_game`).
// and `gamelease::LeaseRequest::spawned`). `None` elsewhere and when nothing was spawned.
#[allow(unused_mut)]
let mut spawned_pid: Option<u32> = None;
// Close this client's previous game first, when the operator asked for that — the compat
@@ -366,8 +365,8 @@ fn run(
(None, None) => Ok(None),
};
match launched {
Ok(l) => {
spawned_pid = l.and_then(|l| l.tracked_pid());
Ok(pid) => {
spawned_pid = pid;
spawned_now = true;
}
Err(e) => {
+46 -144
View File
@@ -176,70 +176,6 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
}
}
/// A resolved Windows launch: the command line to spawn, the directory to spawn it in, and whether
/// the process that line starts **is** the game.
///
/// [`Self::owns_game`] is the whole reason this is a struct and not a pair. Almost every Windows
/// recipe is a protocol hand-off — `explorer.exe "playnite://…"`, `Steam.exe "steam://…"` — that
/// forwards the request to whichever launcher owns the title and then exits. Its pid is a
/// forwarder's, so that pid's lifetime says nothing about the game's, in either direction:
///
/// * the launcher was already running, so the forwarder quits a second later — read as a `Child`
/// lease, that is the game "exiting" while it is still loading;
/// * the launcher was *not* running, so the process the host started becomes the launcher itself
/// and outlives every game the player then quits — a lease that can never report an exit.
///
/// Only a line that starts the game (or the operator's own command) directly earns its pid a place
/// in [`crate::gamelease::LeaseRequest::spawned`]; a hand-off pid is dropped, and the lease falls
/// back to the title's detect signals, exactly as it did before the pid was carried at all.
#[cfg(windows)]
pub struct WinRecipe {
/// The full command line to hand to `CreateProcessAsUserW`.
pub cmdline: String,
/// The working directory to start it in, when the recipe needs a specific one.
pub workdir: Option<std::path::PathBuf>,
/// See the type docs: `false` for a protocol/launcher hand-off.
pub owns_game: bool,
}
#[cfg(windows)]
impl WinRecipe {
/// A line that forwards the launch to whoever owns the title and then exits.
fn handoff(cmdline: String) -> Self {
Self {
cmdline,
workdir: None,
owns_game: false,
}
}
/// A line that starts the game — or the operator's own command — as its own process.
fn game(cmdline: String, workdir: Option<std::path::PathBuf>) -> Self {
Self {
cmdline,
workdir,
owns_game: true,
}
}
}
/// What a Windows launch started, as the lease needs to hear it — see [`WinRecipe::owns_game`].
#[cfg(windows)]
pub struct WindowsLaunch {
/// The pid `CreateProcessAsUserW` handed back.
pub pid: u32,
/// Whether that pid is the game's rather than a forwarder's.
pub owns_game: bool,
}
#[cfg(windows)]
impl WindowsLaunch {
/// The pid to carry on the lease: `None` when all the host started was a hand-off.
pub fn tracked_pid(&self) -> Option<u32> {
self.owns_game.then_some(self.pid)
}
}
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
/// analogue of the Linux gamescope-nested [`resolve_launch`]. The id is resolved against the host's
/// OWN library (the client never sends a command), mapped to a concrete process by
@@ -248,13 +184,12 @@ impl WindowsLaunch {
/// Wired into the data plane *after* capture is live, so the title renders onto the already-captured
/// desktop and grabs foreground.
///
/// Returns the process it started and whether that process is the game ([`WindowsLaunch`]) — the
/// pid is what the caller hands to [`crate::gamelease::LeaseRequest::spawned`], but only when it
/// belongs to the game. It used to be logged and discarded, and that was the whole of Windows'
/// disadvantage against Linux here: with no `Child` to hold and no pid kept, a title whose provider
/// supplied no detect hint left the lease nothing to watch or signal.
/// Returns the **pid of the process it started**, which is what the caller hands to
/// [`crate::gamelease::LeaseRequest::spawned`]. It used to be logged and discarded, and that was the
/// whole of Windows' disadvantage against Linux here: with no `Child` to hold and no pid kept, a
/// title whose provider supplied no detect hint left the lease nothing to watch or signal.
#[cfg(windows)]
pub fn launch_title(id: &str) -> Result<WindowsLaunch> {
pub fn launch_title(id: &str) -> Result<u32> {
let entry = all_games()
.into_iter()
.find(|g| g.id == id)
@@ -264,10 +199,8 @@ pub fn launch_title(id: &str) -> Result<WindowsLaunch> {
// A `plugin` entry's recipe comes from the plugin that owns it, and arrives in the same
// (command line, working dir) shape this path already spawns. `windows_launch_for` has no arm
// for the kind, so a failed ask falls through to the "no recipe" error below.
// A plugin publishes a concrete `(command line, working dir)` for its own title, the same shape
// the operator-typed `command` kind produces — so it is spawned, and tracked, on the same terms.
let recipe = plugin_recipe(&entry)
.map(|l| WinRecipe::game(l.command, l.cwd))
let (cmdline, workdir) = plugin_recipe(&entry)
.map(|l| (l.command, l.cwd))
.or_else(|| windows_launch_for(&spec))
.ok_or_else(|| {
anyhow::anyhow!(
@@ -275,21 +208,10 @@ pub fn launch_title(id: &str) -> Result<WindowsLaunch> {
spec.kind
)
})?;
let WinRecipe {
cmdline,
workdir,
owns_game,
} = recipe;
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
.with_context(|| format!("launch '{id}' in the interactive session"))?;
tracing::info!(
launch_id = id,
%cmdline,
pid,
owns_game,
"launched library title in the interactive session"
);
Ok(WindowsLaunch { pid, owns_game })
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
Ok(pid)
}
/// Windows: map a resolved [`LaunchSpec`] to a `(command line, working dir)` to spawn into the
@@ -301,7 +223,7 @@ pub fn launch_title(id: &str) -> Result<WindowsLaunch> {
/// The `plugin` kind is deliberately absent: its answer comes from another process, so it is
/// resolved by [`plugin_recipe`] before this is reached.
#[cfg(windows)]
fn windows_launch_for(spec: &LaunchSpec) -> Option<WinRecipe> {
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
match spec.kind.as_str() {
"steam_appid" => {
if !valid_steam_appid(&spec.value) {
@@ -315,9 +237,7 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<WinRecipe> {
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
None => format!("explorer.exe \"{uri}\""),
};
// Either line is a forwarder: `Steam.exe <uri>` against a running client posts the URI
// and exits, and against a cold one it *becomes* the client. Neither is the game.
Some(WinRecipe::handoff(cmdline))
Some((cmdline, None))
}
// A launcher entry (D4): open the Steam client's own UI. Same Steam.exe-then-explorer ladder
// as `steam_appid`, and the URI is one of exactly two host-owned literals — nothing from the
@@ -332,22 +252,23 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<WinRecipe> {
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
None => format!("explorer.exe \"{uri}\""),
};
Some(WinRecipe::handoff(cmdline))
Some((cmdline, None))
}
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
"epic" => epic_launch_uri(&spec.value)
.map(|uri| WinRecipe::handoff(format!("explorer.exe \"{uri}\""))),
"epic" => epic_launch_uri(&spec.value).map(|uri| (format!("explorer.exe \"{uri}\""), None)),
// GOG: spawn the resolved game exe directly (host-derived from goggame-<id>.info), no Galaxy.
// ...and the one store recipe that is NOT a hand-off: the resolved exe is the game itself.
"gog" => gog_spawn(&spec.value).map(|(cmdline, workdir)| WinRecipe::game(cmdline, workdir)),
"gog" => gog_spawn(&spec.value),
// Xbox/Game Pass: activate the UWP/GDK package by its AUMID (<PFN>!<AppId>) via explorer's
// shell:AppsFolder — which runs in the interactive user session (UWP activation fails as
// SYSTEM/session-0; spawn_in_active_session uses the user token). Guard the charset (the value
// is host-derived from MicrosoftGame.config + AppRepository, but belt-and-suspenders).
"aumid" => valid_aumid(&spec.value).then(|| {
WinRecipe::handoff(format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value))
(
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
None,
)
}),
// Xbox / Game Pass from a library PLUGIN: `<Identity>!<AppId>`, both read straight out of
// `MicrosoftGame.config`. The host completes it into the AUMID.
@@ -366,9 +287,10 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<WinRecipe> {
return None;
}
let pfn = xbox_pfn(identity)?;
Some(WinRecipe::handoff(format!(
"explorer.exe \"shell:AppsFolder\\{pfn}!{app_id}\""
)))
Some((
format!("explorer.exe \"shell:AppsFolder\\{pfn}!{app_id}\""),
None,
))
}
// Playnite: open the game through Playnite's own URI handler, which is what actually knows
// how to start it (Playnite maps the id to whichever store owns the title). explorer.exe
@@ -379,10 +301,10 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<WinRecipe> {
// line). The 2026-08-05 review made `command` operator-only, which refuses a plugin's whole
// reconcile — so without a typed kind the Playnite plugin cannot publish anything at all.
"playnite" => valid_playnite_id(&spec.value).then(|| {
WinRecipe::handoff(format!(
"explorer.exe \"playnite://playnite/start/{}\"",
spec.value
))
(
format!("explorer.exe \"playnite://playnite/start/{}\"", spec.value),
None,
)
}),
// A launcher entry (D4) on Windows: today that is Playnite's Fullscreen app, spawned
// directly (its `playnite://` handler opens the DESKTOP app, so no URI can do this). The
@@ -391,18 +313,16 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<WinRecipe> {
"launcher_ui" => match spec.value.as_str() {
"playnite" => playnite_fullscreen_exe().map(|exe| {
let dir = exe.parent().map(std::path::Path::to_path_buf);
WinRecipe::game(format!("\"{}\"", exe.display()), dir)
(format!("\"{}\"", exe.display()), dir)
}),
_ => None,
},
// Operator-typed custom command (host-owned, never client-set): run it through the shell in the
// interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator
// input — the same trust as the operator typing it — not a client-influenced string.
// `cmd.exe /c <v>` blocks until the operator's command returns, so its pid tracks that
// command's life — the Windows twin of the Linux child the host holds.
"command" => {
let v = spec.value.trim();
(!v.is_empty()).then(|| WinRecipe::game(format!("cmd.exe /c {v}"), None))
(!v.is_empty()).then(|| (format!("cmd.exe /c {v}"), None))
}
_ => None,
}
@@ -824,7 +744,7 @@ pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
/// through the compositor-aware [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_command(cmd: &str) -> Result<WindowsLaunch> {
pub fn launch_gamestream_command(cmd: &str) -> Result<u32> {
let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command");
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
@@ -832,13 +752,9 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<WindowsLaunch> {
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
.context("spawn gamestream command in the interactive session")?;
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
// `cmd.exe /c` waits for the operator's command, so this pid is the command's own life. Should
// the command itself be a forwarder that returns at once, the lease's shim window is what reads
// that as a hand-off rather than as the game exiting.
Ok(WindowsLaunch {
pid,
owns_game: true,
})
// The `cmd.exe` shim's own pid: it exits the moment it has started the real program, which the
// lease reads as a hand-off (inside its shim window) rather than as the game exiting.
Ok(pid)
}
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
@@ -847,7 +763,7 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<WindowsLaunch> {
/// only ever pick an existing title — never inject a command. Linux resolves the id via
/// [`resolve_launch`] and goes through [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_library(id: &str) -> Result<WindowsLaunch> {
pub fn launch_gamestream_library(id: &str) -> Result<u32> {
launch_title(id)
}
@@ -1107,13 +1023,11 @@ mod tests {
let Some(exe) = playnite_fullscreen_exe() else {
return;
};
let r = ui("playnite").expect("resolvable when the exe was found");
let cmd = &r.cmdline;
let (cmd, dir) = ui("playnite").expect("resolvable when the exe was found");
assert!(cmd.contains("Playnite.FullscreenApp.exe"), "{cmd}");
assert!(!cmd.contains("DesktopApp"), "{cmd}");
assert!(!cmd.contains("playnite://"), "{cmd}");
assert_eq!(r.workdir.as_deref(), exe.parent());
assert!(r.owns_game, "the exe is spawned directly, not forwarded");
assert_eq!(dir.as_deref(), exe.parent());
}
#[cfg(target_os = "linux")]
@@ -1164,20 +1078,11 @@ mod tests {
value: v.into(),
})
};
let bp = ui("bigpicture").expect("bigpicture recipe");
assert!(
bp.cmdline.contains("steam://open/bigpicture"),
"line was {:?}",
bp.cmdline
);
assert!(bp.workdir.is_none());
assert!(!bp.owns_game, "a steam:// URI is forwarded to the client");
let desk = ui("desktop").expect("desktop recipe");
assert!(
desk.cmdline.contains("steam://open/main"),
"line was {:?}",
desk.cmdline
);
let (bp, wd) = ui("bigpicture").expect("bigpicture recipe");
assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}");
assert!(wd.is_none());
let (desk, _) = ui("desktop").expect("desktop recipe");
assert!(desk.contains("steam://open/main"), "line was {desk:?}");
assert!(ui("nonsense").is_none());
assert!(ui("").is_none());
}
@@ -1257,10 +1162,9 @@ mod tests {
kind: "steam_appid".into(),
value: "570".into(),
};
let steam_r = windows_launch_for(&steam).expect("steam recipe");
let line = &steam_r.cmdline;
let (line, wd) = windows_launch_for(&steam).expect("steam recipe");
assert!(line.contains("steam://rungameid/570"), "line was {line:?}");
assert!(steam_r.workdir.is_none());
assert!(wd.is_none());
// A non-numeric "appid" (a client trying to inject) is rejected, never interpolated.
let evil = LaunchSpec {
kind: "steam_appid".into(),
@@ -1272,11 +1176,9 @@ mod tests {
kind: "command".into(),
value: "notepad.exe".into(),
};
let cmd_r = windows_launch_for(&cmd).unwrap();
assert_eq!(cmd_r.cmdline, "cmd.exe /c notepad.exe");
assert!(
cmd_r.owns_game,
"`cmd /c` blocks on the operator's command, so its pid is that command's"
assert_eq!(
windows_launch_for(&cmd).unwrap().0,
"cmd.exe /c notepad.exe"
);
// Xbox AUMID → explorer shell:AppsFolder activation; a value without '!' is rejected.
let aumid = LaunchSpec {
@@ -1284,7 +1186,7 @@ mod tests {
value: "Microsoft.X_8wekyb3d8bbwe!Game".into(),
};
assert_eq!(
windows_launch_for(&aumid).unwrap().cmdline,
windows_launch_for(&aumid).unwrap().0,
"explorer.exe \"shell:AppsFolder\\Microsoft.X_8wekyb3d8bbwe!Game\""
);
assert!(windows_launch_for(&LaunchSpec {
+9 -34
View File
@@ -190,25 +190,10 @@ fn main() {
);
}
// Tee every panic into the log ring BEFORE the default hook: a panicking thread otherwise
// Tee every panic through `tracing` BEFORE the default hook: a panicking thread otherwise
// prints only to stderr — absent from the web console's Logs tab (the ring) and gone entirely
// when stderr is detached — so a field report reads "host died, zero errors in the logs".
// The default hook still runs afterwards for the usual stderr message/abort behavior.
//
// 🛑 **The tee goes straight to the ring, NOT through `tracing`.** A panic hook that emits a
// tracing event is a trap: `tracing_subscriber`'s registry is `sharded-slab`-backed and reads a
// `thread_local!` with `LocalKey::with`, so emitting from a thread whose TLS is being torn down
// panics — *inside the hook*. Rust treats a panic raised while the hook is running as
// `MustAbort::PanicInHook` and then deliberately does not format the message ("perhaps that is
// causing the panic"), so the log gets `panicked at <loc>:` followed by a BLANK line and
// `thread panicked while processing panic. aborting.` — the cause erased at exactly the moment
// it mattered. That is precisely what hid the 2026-08-18 teardown abort on .173 (four aborts,
// zero diagnosis) until it was reproduced standalone.
//
// Everything below is TLS-free and cannot panic: `LogRing` is a `OnceLock` + `Mutex`, and
// `thread::current().name()` / `Backtrace::force_capture()` were both verified safe during TLS
// destruction. This does not make a TLS-destructor panic survivable — Rust aborts on those
// regardless — but it does mean the message that names the cause always lands.
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Manual payload downcast (`payload_as_str` needs Rust 1.91; workspace MSRV is 1.82).
@@ -218,24 +203,14 @@ fn main() {
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
.unwrap_or("<non-string panic payload>");
let location = info
.location()
.map(ToString::to_string)
.unwrap_or_else(|| "<unknown>".into());
let thread = std::thread::current()
.name()
.unwrap_or("<unnamed>")
.to_string();
let backtrace = std::backtrace::Backtrace::force_capture();
let ts_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
log_capture::ring().push_remote(
"ERROR",
"punktfunk_host::panic",
&format!("PANIC: {payload} (thread={thread}, at {location})\n{backtrace}"),
ts_ms,
tracing::error!(
thread = std::thread::current().name().unwrap_or("<unnamed>"),
location = %info
.location()
.map(ToString::to_string)
.unwrap_or_else(|| "<unknown>".into()),
backtrace = %std::backtrace::Backtrace::force_capture(),
"PANIC: {payload}"
);
default_panic(info);
}));
+4 -5
View File
@@ -1913,9 +1913,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
let mut spawned_now = false;
// The pid Windows hands back for the process it started, kept so the lease has something of its
// own to watch and to signal even when the title carries no detect signals at all (see
// `gamelease::LeaseRequest::spawned`). `None` on every other platform, whenever nothing was
// spawned, and — crucially — whenever what was spawned is a protocol hand-off rather than the
// game (`library::WinRecipe::owns_game`): a forwarder's pid is not a lifetime signal.
// `gamelease::LeaseRequest::spawned`). `None` on every other platform and whenever nothing was
// spawned.
#[allow(unused_mut)]
let mut spawned_pid: Option<u32> = None;
// Close whatever this client had running before, if the operator asked for that
@@ -1942,8 +1941,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
);
} else {
match crate::library::launch_title(id) {
Ok(launched) => {
spawned_pid = launched.tracked_pid();
Ok(pid) => {
spawned_pid = Some(pid);
spawned_now = true;
}
Err(e) => {