fix(core): remaining 15 sweep lows — client, clipboard, C ABI (v10), FEC, GSO, GTK
ci / docs-site (push) Successful in 58s
ci / web (push) Successful in 1m11s
apple / swift (push) Successful in 1m20s
decky / build-publish (push) Successful in 29s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled

Second and final batch from the 2026-07-20 core-sweep lows:

client:
- connect() timeout now sets `quit` before shutdown, so a handshake that
  completes after the deadline closes with QUIT_CLOSE_CODE instead of
  leaving the host lingering (virtual display up) for a reconnect that
  never comes.
- probe_result: saturating_add on the wire-supplied wire_packets +
  send_dropped counters (debug-build overflow panic / release wrap).
- the standing-latency bleed no longer sets flush_in_window: that flag
  is the ABR's SEVERE (×0.7) verdict, and the bleed fires only on
  provably loss-free windows the controller itself scores as fine.

clipboard:
- fetch_cancels pruned on every new fetch (was: one dead oneshot per
  paste for the session).
- serve chunks gated on a parked waiter + capped at CLIP_FETCH_CAP with
  an Error event (was: unbounded silent accumulation under any req_id).
- serve_inbound park bounded by FETCH_STALL_SECS + send.stopped() (was:
  an unanswered FetchRequest parked the task, waiter, and bi-stream
  forever; ~100 of them exhaust the connection's bidi budget).

C ABI (ABI_VERSION 9 → 10, header regenerated, additive only):
- new punktfunk_connection_clock_offset_now_ns — the LIVE re-synced
  offset (Swift/Kotlin latency math read the frozen connect-time value
  ~40ms wrong after a wall-clock step).
- to_config: checked u64→usize narrowing of max_frame_bytes (32-bit
  armeabi truncated >4GiB to a plausible residue).
- host_poll_input: no &mut held across the embedder callback (re-entry
  aliased it — UB under noalias); mid-drain callback clears now stick.
- next_audio_pcm: DTX (empty) payloads skipped — decode synthesized
  120ms of concealment per 5ms slot and grew the playout ring forever.
- next_clipboard releases the parked payload on an empty poll (a one-off
  50MiB paste stayed resident all session).
- frames_dropped / wants_decode_latency write their documented 0/false
  defaults before the NULL-handle check.
- gamepad constant docs match pick_gamepad() reality (DualSenseEdge/
  SwitchPro landed; DualSense/DS4 honored on Windows UMDF too).

FEC:
- gf8 reconstruct/reconstruct_into reuse the (k,m) codec cache like
  encode_into (was: fresh 230×200 generator + decode inversion per
  lossy block on the pump thread).
- vendored fec-rs: the 8 safe wrap_mul_slice shims assert equal lengths
  (x86 SIMD callees bound stores on input.len() — safe-code OOB write);
  ReconstructShard's safety contract gains the len()==get().len()
  clause and reconstruct_internal sizes raw slices from the slice.

transport/GTK:
- Linux GSO super-buffer capped at the real UDP payload ceiling (65487)
  not 65535 — seg sizes ≥1024 could EMSGSIZE and latch GSO off
  process-wide, blamed on the network.
- GTK settings dialog no longer rewrites an unlisted-but-valid stored
  gamepad preference to "auto" on close.

Already fixed on main (verified stale, skipped): the reassembler FEC
ceiling, wants_decode_latency's third term, request_probe rollback +
the generalized probe watchdog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:26:43 +02:00
parent 76ac3cf867
commit de7b54d4e0
12 changed files with 337 additions and 50 deletions
+118 -7
View File
@@ -145,6 +145,11 @@ pub async fn run(
let Some(cmd) = cmd else { break }; // NativeClient dropped
match cmd {
ClipCommand::Fetch { xfer_id, seq, file_index, mime } => {
// Prune finished fetches first: a completed/failed/timed-out fetch task
// drops its cancel receiver, so its sender reads closed. Without this
// the map grew by one dead sender per paste for the whole session (only
// an explicit Cancel ever removed entries).
fetch_cancels.retain(|_, tx| !tx.is_closed());
let (cancel_tx, cancel_rx) = oneshot::channel();
fetch_cancels.insert(xfer_id, cancel_tx);
let conn = conn.clone();
@@ -153,11 +158,36 @@ pub async fn run(
tokio::spawn(run_outbound_fetch(conn, xfer_id, req, events, cancel_rx));
}
ClipCommand::Serve { req_id, bytes, last } => {
serve_bufs.entry(req_id).or_default().extend_from_slice(&bytes);
if last {
let full = serve_bufs.remove(&req_id).unwrap_or_default();
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) {
let _ = tx.send(Some(full));
// Gate on a genuinely parked fetch (the waiter registers before the
// FetchRequest event is emitted, so a live serve always finds it):
// bytes served under a stale/unknown/cancelled req_id would otherwise
// pool here for the whole session with Ok returned for every chunk.
if !serve_waiters.lock().unwrap().contains_key(&req_id) {
serve_bufs.remove(&req_id);
} else {
let buf = serve_bufs.entry(req_id).or_default();
if buf.len().saturating_add(bytes.len()) > CLIP_FETCH_CAP {
// The requester bounds its read at CLIP_FETCH_CAP — anything
// larger is memory burned toward a guaranteed peer rejection.
// Fail the transfer NOW: peer reads UNAVAILABLE, embedder gets
// an Error instead of silent Ok-per-chunk.
serve_bufs.remove(&req_id);
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id) {
let _ = tx.send(None);
}
let _ = events.try_send(ClipEventCore::Error {
id: req_id,
code: PunktfunkStatus::InvalidArg as i32,
});
} else {
buf.extend_from_slice(&bytes);
if last {
let full = serve_bufs.remove(&req_id).unwrap_or_default();
if let Some(tx) = serve_waiters.lock().unwrap().remove(&req_id)
{
let _ = tx.send(Some(full));
}
}
}
}
}
@@ -224,8 +254,27 @@ async fn serve_inbound(
return;
}
match rx.await {
Ok(Some(bytes)) => {
// Overall stall bound (§3.4) — the inbound mirror of `run_outbound_fetch`'s: an embedder
// that never answers must not hold the waiter, this task, and the accepted bi-stream open
// for the rest of the session (~100 unanswered pastes exhaust the connection's bidi-stream
// budget and every later host paste stalls). `send.stopped()` additionally wakes us the
// moment the peer gives up on its side of the fetch.
let answer = tokio::select! {
r = rx => r.ok().flatten(),
_ = send.stopped() => {
let _ = events.try_send(ClipEventCore::Cancelled { id: req_id });
None
}
_ = tokio::time::sleep(std::time::Duration::from_secs(FETCH_STALL_SECS)) => {
let _ = events.try_send(ClipEventCore::Cancelled { id: req_id });
None
}
};
// Idempotent — the answering/denying paths already removed it; the stall paths must.
waiters.lock().unwrap().remove(&req_id);
match answer {
Some(bytes) => {
if clipstream::write_fetch_hdr(
&mut send,
&ClipFetchHdr {
@@ -302,3 +351,65 @@ async fn run_outbound_fetch(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::quic::test_util::connect_pair;
use crate::quic::CLIP_FILE_INDEX_NONE;
/// A serve chunk that alone breaches the requester-side CLIP_FETCH_CAP must fail the
/// transfer immediately — Error to the embedder, UNAVAILABLE to the peer — instead of
/// accumulating Ok-per-chunk toward a guaranteed peer-side rejection. Also pins the
/// waiter-membership gate: the serve only accumulated because the fetch was parked.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_serve_fails_the_transfer_instead_of_buffering() {
let (_s, _c, host_conn, client_conn) = connect_pair().await;
let (ev_tx, ev_rx) = std::sync::mpsc::sync_channel(16);
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(run(client_conn, ev_tx, cmd_rx));
// Host pastes: it opens a fetch bi-stream toward the client.
let req = ClipFetch {
seq: 1,
file_index: CLIP_FILE_INDEX_NONE,
mime: "text/plain;charset=utf-8".into(),
};
let (_send, mut recv) = clipstream::open_fetch(&host_conn, &req).await.unwrap();
// The client core surfaces the FetchRequest (the waiter is parked now).
let (req_id, ev_rx) = tokio::task::spawn_blocking(move || {
match ev_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
{
ClipEventCore::FetchRequest { req_id, .. } => (req_id, ev_rx),
other => panic!("expected FetchRequest, got {other:?}"),
}
})
.await
.unwrap();
cmd_tx
.send(ClipCommand::Serve {
req_id,
bytes: vec![0u8; CLIP_FETCH_CAP + 1],
last: false,
})
.unwrap();
let ev = tokio::task::spawn_blocking(move || {
ev_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap()
})
.await
.unwrap();
match ev {
ClipEventCore::Error { id, .. } => assert_eq!(id, req_id),
other => panic!("expected Error, got {other:?}"),
}
// The peer's read side sees the transfer refused, not a hang.
let hdr = clipstream::read_fetch_hdr(&mut recv).await.unwrap();
assert_eq!(hdr.status, CLIP_FETCH_UNAVAILABLE);
}
}