Author SHA1 Message Date
molluskandClaude Opus 4.8 87de5213fe audio: cover ordinary serial lengths in parse tests
Codex round 1 (P3): the valid cases were only 1, 10 and 20 digits long,
so `if (2..10).contains(&raw.len()) { return None }` survived all four
tests while rejecting every serial a freshly started daemon hands out.
Verified: that mutant passes the old suite and fails the new test.

Also corrects the doc comment — leading zeroes are accepted (harmless
and unambiguous), only whitespace padding is rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:00:34 -04:00
molluskandClaude Opus 4.8 9b6c8bb5c3 audio: parse object.serial as u64 (phase 0a)
`object.serial` is a 64-bit PipeWire counter, not a u32 object id.
Parsing it with `parse::<u32>()` returns None past u32::MAX, which
silently leaves `RouterState::sink_serial` unset — `try_flush` then
routes nothing and app-filter mode is dead with no diagnostic.

- factor the parse into a pure `parse_object_serial(&str) -> Option<u64>`
  (strict decimal; rejects signs, padding, overflow) with unit tests at
  the u32 boundary, past it, and at u64::MAX
- widen `RouterState::sink_serial` to `Option<u64>`
- log a warning when the sink's serial is unusable instead of returning
  silently
- audit the other `parse::<u32>` in this file: `load_module` returns a
  PulseAudio module index (uint32_t), genuinely 32-bit — annotated, not
  changed

Prerequisite for the taint engine's lifetime-awareness, which is keyed
on object.serial (screenshare-audio-exclusion-impl-plan.md §1, §2/0a).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:47:30 -04:00
molluskandClaude Opus 4.8 40604c716c debug: add PIXELPASS_TS_DUMP tap for A/V drift analysis
When PIXELPASS_TS_DUMP=<path> is set, tee the muxed MPEG-TS to a file in
addition to the normal fd=1 serve path, so the host-side stream can be
ffprobe'd for capture-side audio/video PTS drift. Each tee branch gets its
own queue so the disk sink cannot backpressure the live serve branch.

No effect when the variable is unset, mirroring PIXELPASS_GST_DEBUG.

Used to establish that the host produces an A/V-clean realtime stream
(+/-18 ms over 170 s), ruling out the capture side in the screen-share
drift investigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:49:42 -04:00
mollusk 3b92bcbe52 chore: update dependencies for RustSec advisories 2026-07-15 06:29:05 -04:00
5 changed files with 781 additions and 629 deletions
Generated
+584 -591
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -32,7 +32,7 @@ name = "pixelpass"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
iroh = "1.0.0-rc.0" iroh = "1.0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] }
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
@@ -48,7 +48,7 @@ ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
pipewire = "0.9" pipewire = "0.9"
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] } x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0-rc.0" iroh-tickets = "1.0.0"
dialoguer = { version = "0.12", default-features = false } dialoguer = { version = "0.12", default-features = false }
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] } arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
ureq = { version = "3", default-features = false, features = ["rustls"] } ureq = { version = "3", default-features = false, features = ["rustls"] }
+53 -26
View File
@@ -191,11 +191,10 @@ fn encode_section() -> Section {
/// no video — the exact silent failure `--no-hwencode` works around. /// no video — the exact silent failure `--no-hwencode` works around.
fn hardware_encode_check() -> Check { fn hardware_encode_check() -> Check {
if !deps::gst_element_exists("vah264enc") { if !deps::gst_element_exists("vah264enc") {
return Check::warn("hardware H.264", "vah264enc plugin not installed") return Check::warn("hardware H.264", "vah264enc plugin not installed").with_hint(format!(
.with_hint(format!( "{} — or just host with `--no-hwencode` (software x264)",
"{} — or just host with `--no-hwencode` (software x264)", deps::install_hint_for_gst_element("vah264enc")
deps::install_hint_for_gst_element("vah264enc") ));
));
} }
if !has_render_node() { if !has_render_node() {
@@ -207,9 +206,10 @@ fn hardware_encode_check() -> Check {
} }
match vainfo_output() { match vainfo_output() {
Some(out) if vainfo_has_h264_encode(&out) => { Some(out) if vainfo_has_h264_encode(&out) => Check::ok(
Check::ok("hardware H.264", "VA-API H.264 encode available (vah264enc)") "hardware H.264",
} "VA-API H.264 encode available (vah264enc)",
),
Some(_) => Check::warn( Some(_) => Check::warn(
"hardware H.264", "hardware H.264",
"vah264enc present, but VA-API reports no H.264 encode entrypoint", "vah264enc present, but VA-API reports no H.264 encode entrypoint",
@@ -237,15 +237,28 @@ fn software_encode_check() -> Check {
fn mux_audio_section() -> Section { fn mux_audio_section() -> Section {
// These live in plugins-bad/-good/-libav and plugins-base; all are required // These live in plugins-bad/-good/-libav and plugins-base; all are required
// for either backend, so a miss here is a hard Fail. // for either backend, so a miss here is a hard Fail.
let tail = ["h264parse", "mpegtsmux", "aacparse", "avenc_aac", "pulsesrc", "videoscale"]; let tail = [
let missing: Vec<&str> = tail.iter().copied().filter(|e| !deps::gst_element_exists(e)).collect(); "h264parse",
"mpegtsmux",
"aacparse",
"avenc_aac",
"pulsesrc",
"videoscale",
];
let missing: Vec<&str> = tail
.iter()
.copied()
.filter(|e| !deps::gst_element_exists(e))
.collect();
let tail_check = if missing.is_empty() { let tail_check = if missing.is_empty() {
Check::ok("mux + audio tail", tail.join(", ")) Check::ok("mux + audio tail", tail.join(", "))
} else { } else {
Check::fail("mux + audio tail", format!("missing: {}", missing.join(", "))).with_hint( Check::fail(
deps::install_hint_for_gst_element(missing[0]), "mux + audio tail",
format!("missing: {}", missing.join(", ")),
) )
.with_hint(deps::install_hint_for_gst_element(missing[0]))
}; };
Section { Section {
@@ -278,7 +291,9 @@ fn viewer_section() -> Section {
async fn network_section(relay: Option<&str>) -> Section { async fn network_section(relay: Option<&str>) -> Section {
let check = match endpoint::bind(relay).await { let check = match endpoint::bind(relay).await {
Ok(ep) => { Ok(ep) => {
let online = tokio::time::timeout(Duration::from_secs(8), ep.online()).await.is_ok(); let online = tokio::time::timeout(Duration::from_secs(8), ep.online())
.await
.is_ok();
let relay_count = ep.addr().addrs.iter().filter(|a| a.is_relay()).count(); let relay_count = ep.addr().addrs.iter().filter(|a| a.is_relay()).count();
let where_ = relay.map(|r| format!(" ({r})")).unwrap_or_default(); let where_ = relay.map(|r| format!(" ({r})")).unwrap_or_default();
// Close gracefully so iroh doesn't log a scary "Endpoint dropped // Close gracefully so iroh doesn't log a scary "Endpoint dropped
@@ -288,11 +303,17 @@ async fn network_section(relay: Option<&str>) -> Section {
if online && relay_count > 0 { if online && relay_count > 0 {
Check::ok("relay", format!("home relay reachable{where_}")) Check::ok("relay", format!("home relay reachable{where_}"))
} else if online { } else if online {
Check::warn("relay", format!("endpoint online but no relay address{where_}")) Check::warn(
.with_hint("n0 DNS discovery may still connect peers, but relay fallback is degraded") "relay",
format!("endpoint online but no relay address{where_}"),
)
.with_hint(
"n0 DNS discovery may still connect peers, but relay fallback is degraded",
)
} else { } else {
Check::warn("relay", format!("no relay connected within 8s{where_}")) Check::warn("relay", format!("no relay connected within 8s{where_}")).with_hint(
.with_hint("check connectivity/firewall; peers behind NAT rely on the relay to rendezvous") "check connectivity/firewall; peers behind NAT rely on the relay to rendezvous",
)
} }
} }
Err(e) => Check::fail("relay", format!("could not bind endpoint: {e}")), Err(e) => Check::fail("relay", format!("could not bind endpoint: {e}")),
@@ -318,8 +339,11 @@ fn gst_check(element: &str, label: &str) -> Check {
if deps::gst_element_exists(element) { if deps::gst_element_exists(element) {
Check::ok(label, element.to_string()) Check::ok(label, element.to_string())
} else { } else {
Check::fail(label, format!("GStreamer element `{element}` not available")) Check::fail(
.with_hint(deps::install_hint_for_gst_element(element)) label,
format!("GStreamer element `{element}` not available"),
)
.with_hint(deps::install_hint_for_gst_element(element))
} }
} }
@@ -392,7 +416,12 @@ fn os_release_field(key: &str) -> Option<String> {
fn display_env_summary() -> String { fn display_env_summary() -> String {
let mut parts = Vec::new(); let mut parts = Vec::new();
for var in ["WAYLAND_DISPLAY", "DISPLAY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP"] { for var in [
"WAYLAND_DISPLAY",
"DISPLAY",
"XDG_SESSION_TYPE",
"XDG_CURRENT_DESKTOP",
] {
if let Some(v) = std::env::var_os(var) { if let Some(v) = std::env::var_os(var) {
parts.push(format!("{var}={}", v.to_string_lossy())); parts.push(format!("{var}={}", v.to_string_lossy()));
} }
@@ -408,11 +437,9 @@ fn has_render_node() -> bool {
let Ok(entries) = std::fs::read_dir("/dev/dri") else { let Ok(entries) = std::fs::read_dir("/dev/dri") else {
return false; return false;
}; };
entries.flatten().any(|e| { entries
e.file_name() .flatten()
.to_string_lossy() .any(|e| e.file_name().to_string_lossy().starts_with("renderD"))
.starts_with("renderD")
})
} }
fn vainfo_output() -> Option<String> { fn vainfo_output() -> Option<String> {
@@ -559,7 +586,7 @@ mod tests {
#[test] #[test]
fn summarize_counts_fails_and_warns_only() { fn summarize_counts_fails_and_warns_only() {
let checks = vec![ let checks = [
Check::ok("a", "x"), Check::ok("a", "x"),
Check::info("b", "x"), Check::info("b", "x"),
Check::warn("c", "x"), Check::warn("c", "x"),
+117 -8
View File
@@ -365,6 +365,9 @@ fn load_module(args: &[&str]) -> Result<u32> {
.context("pactl returned non-UTF-8")? .context("pactl returned non-UTF-8")?
.trim() .trim()
.to_string(); .to_string();
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
// back verbatim. Do not widen it.
id_str id_str
.parse::<u32>() .parse::<u32>()
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}")) .with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
@@ -532,13 +535,21 @@ fn run_router(
return; return;
}; };
if props.get("node.name") == Some(sink_name_owned.as_str()) { if props.get("node.name") == Some(sink_name_owned.as_str()) {
if let Some(serial) = props match props.get("object.serial").and_then(parse_object_serial) {
.get("object.serial") Some(serial) => {
.and_then(|s| s.parse::<u32>().ok()) state_for_reg.borrow_mut().sink_serial = Some(serial);
{ tracing::info!(serial, "audio routing: pixelpass sink registered");
state_for_reg.borrow_mut().sink_serial = Some(serial); try_flush(&state_for_reg, &event_tx_for_reg);
tracing::info!(serial, "audio routing: pixelpass sink registered"); }
try_flush(&state_for_reg, &event_tx_for_reg); // Never silently: without a serial `try_flush` can
// never route anything, so the whole app-filter mode
// is dead and the only symptom is missing audio.
None => tracing::warn!(
node_id = obj.id,
serial = props.get("object.serial").unwrap_or("<absent>"),
"audio routing: pixelpass sink has no usable object.serial; \
stream rerouting disabled"
),
} }
return; return;
} }
@@ -591,8 +602,30 @@ fn run_router(
Ok(()) Ok(())
} }
/// Parse a PipeWire `object.serial` property value.
///
/// `object.serial` is a **64-bit** monotonically-increasing counter
/// (`pw_global`'s serial is `uint64_t`); it is *not* a `pw` object id
/// (those are `u32` and get recycled — the serial exists precisely so
/// that recycled ids can be disambiguated). Parsing it as `u32` silently
/// yields `None` past `u32::MAX`, which on a long-lived daemon means the
/// sink is never registered and no stream is ever routed.
///
/// Strict on purpose: PipeWire emits a bare decimal, so anything else
/// (empty, signed, whitespace-padded, non-numeric, overflowing) is a
/// property we do not understand and must not guess at. Leading zeroes
/// are accepted — they are unambiguous and parse to the same value.
fn parse_object_serial(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
raw.parse::<u64>().ok()
}
struct RouterState { struct RouterState {
sink_serial: Option<u32>, /// See [`parse_object_serial`] — 64-bit, and not interchangeable with
/// the `u32` node ids in `routed_node_ids` / `pending`.
sink_serial: Option<u64>,
default_metadata: Option<pipewire::metadata::Metadata>, default_metadata: Option<pipewire::metadata::Metadata>,
routed_node_ids: Vec<u32>, routed_node_ids: Vec<u32>,
pending: Vec<u32>, pending: Vec<u32>,
@@ -654,3 +687,79 @@ fn try_flush(
let _ = event_tx.send(Event::FirstRoutedStream); let _ = event_tx.send(Event::FirstRoutedStream);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn object_serial_parses_past_u32() {
// The regression this fix exists for: a serial one past `u32::MAX`
// used to parse as `None` and silently disable rerouting.
let beyond = u64::from(u32::MAX) + 1;
assert_eq!(parse_object_serial(&beyond.to_string()), Some(beyond));
assert_eq!(
parse_object_serial(&u64::MAX.to_string()),
Some(u64::MAX),
"the full 64-bit range must round-trip"
);
}
#[test]
fn object_serial_accepts_ordinary_serials() {
// Without this the valid cases are only 1, 10 and 20 digits long, and
// a length-gated mutant (`if (2..10).contains(&raw.len()) { None }`)
// survives the whole suite while rejecting every serial a freshly
// started daemon actually hands out. (Codex, round 1.)
for serial in 0_u64..=1024 {
assert_eq!(parse_object_serial(&serial.to_string()), Some(serial));
}
assert_eq!(parse_object_serial("123456789"), Some(123_456_789));
assert_eq!(
parse_object_serial("007"),
Some(7),
"leading zeroes are fine"
);
}
#[test]
fn object_serial_boundary_values() {
assert_eq!(parse_object_serial("0"), Some(0));
assert_eq!(parse_object_serial("1"), Some(1));
let max32 = u64::from(u32::MAX);
assert_eq!(parse_object_serial(&max32.to_string()), Some(max32));
assert_eq!(
parse_object_serial(&(max32 - 1).to_string()),
Some(max32 - 1)
);
}
#[test]
fn object_serial_round_trips_through_the_metadata_string() {
// `try_flush` writes the serial back out as a decimal string for
// `target.object`; widening must not introduce a formatting change.
for raw in ["0", "4294967296", "18446744073709551615"] {
let parsed = parse_object_serial(raw).expect("valid serial");
assert_eq!(parsed.to_string(), raw);
}
}
#[test]
fn object_serial_rejects_malformed() {
for raw in [
"",
" 12",
"12 ",
"+12",
"-1",
"1.0",
"0x10",
"12a",
"abc",
// u64::MAX + 1 — overflow must be rejected, not wrapped.
"18446744073709551616",
] {
assert_eq!(parse_object_serial(raw), None, "should reject {raw:?}");
}
}
}
+25 -2
View File
@@ -189,9 +189,32 @@ fn build_args(
"!".into(), "!".into(),
"queue".into(), "queue".into(),
"!".into(), "!".into(),
"fdsink".into(),
"fd=1".into(),
]; ];
// Debug A/V-drift tap: when PIXELPASS_TS_DUMP=<path> is set, tee the exact
// muxed TS both to fd=1 (normal serve path, unchanged) and to a file, so the
// host-side stream can be ffprobe'd for capture-side audio/video PTS drift.
// Each tee branch has its own queue so the disk sink can't backpressure the
// live serve branch. No effect when unset. (Mirrors PIXELPASS_GST_DEBUG.)
if let Some(dump) = std::env::var_os("PIXELPASS_TS_DUMP") {
let path = dump.to_string_lossy().into_owned();
args.extend([
"tee".into(),
"name=dbgtee".into(),
"!".into(),
"queue".into(),
"!".into(),
"fdsink".into(),
"fd=1".into(),
"dbgtee.".into(),
"!".into(),
"queue".into(),
"!".into(),
"filesink".into(),
format!("location={path}"),
]);
} else {
args.extend(["fdsink".into(), "fd=1".into()]);
}
// Downscale step for the quality presets. `None` = encode at native size // Downscale step for the quality presets. `None` = encode at native size
// (the "Source" preset, or a source already at/below the target height — we // (the "Source" preset, or a source already at/below the target height — we