Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
071c4041e4 | ||
|
|
582759b3cb | ||
|
|
baf011f7a7 | ||
|
|
a823bcf6ae | ||
|
|
5c6236aec9 | ||
|
|
95637f3226 |
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user