Field-test regression from the F-06 fix (a17b930): joining a room via the
friends-list / Recents Join button sometimes landed in an empty roster even
though the peer was fully connected at the gossip layer.
Cause: `PeerJoined` (gossip event task) and `RoomJoined` (core command loop)
ride the same UI channel from different senders. The core emits `RoomJoined`
only after audio + echo-cancel setup, so `PeerJoined` for the new room
routinely arrives first. F-06 had added `reset_room_state()` to the
`RoomJoined` handler, which then cleared the peer that had already announced.
Echo cancellation widened the window and made it reliable; the roster
"self-healed" only on the peer's next periodic re-announce (`PeerUpdated`).
Fix: reset room-scoped UI state at join *initiation* (JoinPressed,
CreatePressed, JoinFriendRoom, JoinRecent) instead of on `RoomJoined`. From
Home that's a no-op (already cleared on leave), so nothing leaks, and an
early `PeerJoined` for the new room now survives. The in-call switch path
F-06 targeted is unreachable from the current UI (friends list + Recents
render only on the Home screen), so this fully covers the reachable case.
Field-verified on a 2-machine desktop<->dopedart call. 407 lib tests pass,
clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four confirmed P2 findings from the 2026-06-22 adversarial bug sweep.
None change the wire format / PeerState / GOSSIP_PROTO — all local.
- F-05: re-key the A8 rejoin archive (known_peers) and RecoveryContext
by topic_id ([u8; 32]) instead of the raw ticket string. A W7-
restamped member ticket shares the room's topic but not its string,
so a rejoin-from-Recents previously missed the retained bootstrap
bucket and dropped to an empty bootstrap — the exact dead-end A8
fixed. Topic is derived once via PeerSpeakTicket::topic_of in Join;
a malformed ticket now fails early and clean.
- F-06: an in-call Join no longer leaks the old room's peers/chat into
the new room, nor strands stale presence on a failed switch. Core
captures was_in_room, clears current_room at teardown, and emits a
new local UiEvent::RoomReset on every post-teardown failure path so
a failed switch lands idle on Home. The UI's room-scoped clearing is
factored into AppState::reset_room_state(), called by RoomLeft,
RoomReset, and at the top of RoomJoined — so a successful switch
clears+repopulates seamlessly on the Room screen (no Home bounce, no
leave chime).
- F-10: echo-cancel virtual nodes now get per-PID-unique names
(peerspeak_echocancel_{source,sink}.<pid>); the guard carries them
and core targets them instead of the fixed constants. unload_stale
only unloads our modules whose owner PID is dead (/proc check, cfg-
gated; conservative elsewhere), so enabling AEC in one instance can
no longer tear down another live instance's call. Pure
pid_from_ec_args / ec_module_is_stale seams.
- F-11: a recording write failure now stops recording atomically
(best-effort finalize via stop_recording + one UI Error) instead of
looping the error at ~50 Hz with silent data loss. Both mixer
branches release the recorder mutex before calling stop_recording to
avoid a self-deadlock on the non-reentrant std::Mutex.
407 lib tests pass (+4), clippy --all-targets clean, release build
green. Tests-green only; the rejoin (F-05), in-call switch (F-06),
two-instance AEC (F-10), and disk-full (F-11) paths need a real run.
Implemented by Codex, reviewed + gates re-run by senior.
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five confirmed findings from the 2026-06-22 adversarial bug sweep:
- S-01: clamp PipeWire capture chunk size to the mapped slice before
indexing, so a bad reported size can't panic (= process abort) from
the RT capture callback. Extracted testable for_each_capture_sample.
- F-04: reserve ring occupancy before publishing a frame on the PipeWire
playback path (mirrors the cpal fix), preventing the RT consumer from
popping an uncounted sample and wrapping fill_gauge to usize::MAX,
which permanently wedged mixer pacing. Extracted publish_frame.
- F-09: GameDetector::spawn now returns io::Result and retains its
JoinHandle (joined on Drop); core fuses a closed watch receiver to
None via next_game_change so a dead detector can't busy-loop select!.
- F-08: collision-free recording paths — Recorder::create and the
multitrack session dir use create_new/create_dir with bounded suffix
retry, so two recordings in the same second no longer truncate the
first.
- S-02: bound the Windows SteamPath registry read (<=4 KiB, even length,
re-checked type/returned length) before allocating/decoding.
403 lib tests pass (+6), clippy --all-targets clean. Implemented by
Codex, reviewed + gates re-run by senior.
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the new PeerState.game field to the presence log formatter so
gossip Announces show whether a peer is broadcasting a game. The line
previously printed name/muted/addr_id/addrs/sharing only, making the
game-presence broadcast invisible in logs (verified solely via UI
during the 2026-06-22 2-machine field test). Log-only: no wire,
protocol, or GOSSIP_PROTO change. Adds the first unit test for the
formatter (Some and None cases).
Implemented by Codex (gpt-5.5) on branch codex-log-game-field; reviewed
and gates re-run by the senior (397 lib tests, clippy --all-targets,
release build all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field test found Steam games were never detected on Linux. Root cause:
Steam rewrites ~/.steam/registry.vdf only on SHUTDOWN, so its RunningAppID
is stale (often absent) while a game is actually running — polling it can
never see the live game.
Fix: on Linux, read the live appid from the running game's environment
(SteamAppId in /proc/<pid>/environ, the var Steam exports to every game
process — the same signal MangoHud uses; readable for our own processes).
registry.vdf stays as a best-effort fallback. Windows still reads the real
registry's RunningAppID, which IS updated live there. Other Unix keeps the
registry.vdf fallback.
Pure parse_steam_app_id_from_environ() is unit-tested (nonzero filter,
absent, substring-not-fooled, garbage). Also fixes a latent bug in the
first draft where a single non-UTF8 SteamAppId value would abort the whole
scan via ? instead of skipping.
396 lib tests, clippy --all-targets clean.
Final step of game detection. Functional, plain styling (to art-direct).
- Settings 'Games' category: opt-in 'show my game' toggle
(SetGamePresenceEnabled, persisted), manual override picker (Auto /
None / Pin current), per-game background picker+remove (reuses
process_background + hashed game_background_path), and a non-Steam
process->name mapping editor (add/remove, pushes SetGameProcessMap).
- Roster: each peer card shows 'Playing <game>' under their name when
they broadcast one; our own self card shows it too, marked
'(not shared)' when broadcasting is off.
- Startup: seeds SetGamePresenceEnabled + SetGameProcessMap from config.
- Updated the settings-category navigation test for the new category.
395 lib tests green, clippy --all-targets clean, binary builds, and an
8s smoke launch starts the core + detector thread with no panic (detector
logs nothing by design — privacy).
Feature complete on Linux end-to-end (pending a coordinated GOSSIP_PROTO
3 redeploy to field-test presence with peers). Windows FFI still needs
its cross-build pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Steps 5-6 of game detection. BREAKING wire change — bump everyone.
Wire (step 5):
- PeerState.game: Option<String> (display label only — never appid/source).
- SelfPresence.game + to_state carry it (single self-state builder).
- GOSSIP_PROTO 2->3, GOSSIP_SIG_DOMAIN v3, version comment bumped together;
Cargo MINOR 0.3.0 -> 0.4.0 per VERSIONING.md. v2/v3 isolate into
different topics + signature domains, so a coordinated redeploy is
required (same as the W4 avatar bump).
- Gossip ingest sanitizes incoming game via sanitize_game_label (bidi/
control strip, 64-char/256-byte cap); empty -> None.
- Bonus security fix (Codex find): reject inbound gossip frames over a
128KB cap BEFORE serde_json::from_slice — a legit Announce with a full
48KB avatar is ~49KB, so this bounds allocation abuse with headroom.
Core wiring:
- Spawns the detector at startup; consumes its watch channel in the main
select. Detection runs continuously (for the local background); the
broadcast is gated by game_presence_enabled (opt-in, default OFF).
New commands: SetGamePresenceEnabled (immediate publish/clear, D8),
SetGameOverride, SetGameProcessMap. New event: GameChanged.
- game_presence_label sanitizes the outgoing label too.
Background switch (step 6):
- GUI handles GameChanged: stores current_game, swaps background to the
per-game override (config.game_backgrounds[id]) or falls back to the
W16 default; reuses the existing cached-handle path (no redraw flicker).
397 lib tests (all green), clippy --all-targets clean, full binary builds.
Remaining: step 7 UI (opt-in toggle, roster 'Playing' text, manual
override control, Settings game-backgrounds + process-map editors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 4 of game detection. One std-thread worker owns the SteamProbe cache
+ Debouncer across ticks, polls the OS adapters every 3s off the async
runtime, and publishes the stable detected game on a tokio watch channel
only when it changes. Manual override + process map are live-updatable via
shared handles; a cancellable sleep honors stop promptly; drop stops it.
The per-tick decision (match + resolve + debounce) is the pure poll_once,
unit-tested with synthetic Steam/process inputs (debounce, process-only
match, immediate manual override). +4 tests (397 lib).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 2-3 of game detection (game-presence-plan.md). The OS edges feeding
the pure seams from the previous commit.
- src/game/steam.rs: SteamProbe — reads the live RunningAppID and resolves
it to a name via appmanifest_<id>.acf (no binary appinfo.vdf). Pure parse
fns (parse_running_app_id / parse_library_paths / parse_app_name) over
file contents are unit-tested incl. current+legacy libraryfolders shapes,
escaped Windows paths, empty/missing names, and garbage. Roots discovered
across native/Flatpak/Snap (Linux) and the registry (Windows); libraries
and resolved names cached + mtime-invalidated so the 3s poll doesn't
rescan. File reads byte-capped.
- src/game/scan.rs: native running-process enumeration — /proc (exe symlink,
comm fallback) on Linux, Toolhelp on Windows — feeding the pure
match_processes. No sysinfo dep (D7).
- Cargo.toml: windows-sys as a direct Windows-only dep for the registry +
Toolhelp FFI. No NEW crate — it was already in the lockfile transitively
via cpal/rfd, so the audit surface is unchanged.
391 lib tests (+5). Linux: build + clippy --all-targets clean. Windows FFI
signatures verified against windows-sys 0.61 source (one *const vs *mut
lpReserved fixed) but NOT yet cross-compiled — defer to the post-UI Windows
build cycle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 1 of the game-detection feature (game-presence-plan.md): all the
pure, I/O-free logic, tested first.
- src/game/mod.rs: DetectedGame + stable namespaced ids (steam:730 /
exe:hl2_linux, never the mutable name); ManualOverride; the priority
resolve() matcher (override -> Steam -> mapped process -> none); the
Debouncer (2-on/3-off, immediate bypass for manual override) that
stops a flapping detector re-announcing the ~48KB-avatar PeerState;
match_processes() over explicit user mappings with a launcher denylist
(never guesses a game from an arbitrary process).
- src/game/vdf.rs: a real recursive-descent KeyValues/VDF parser (not a
name-regex) for appmanifest/.acf, libraryfolders.vdf, registry.vdf —
depth-capped, escape-aware, never panics on malformed/truncated input.
- src/sanitize.rs: sanitize_game_label (64-char/256-byte cap, wider than
the 48-char name cap) sharing the bidi/zero-width cleaning.
- src/config.rs: additive game_presence_enabled (opt-in, default OFF),
game_backgrounds + game_process_map (BTreeMap, deterministic);
background_path generalized to hashed per-game files; explicit
legacy-config migration test (load() wipes on any deserialize error).
- src/background.rs: game_background_filename (FNV-1a hashed, fs-safe).
No wire/protocol change yet; no OS reads yet. 386 lib tests (+28).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Core reconstructed PeerState in five command branches (join, mute
toggle, avatar change, screen-share start/stop), each repeating the full
field list. Factor a SelfPresence struct holding the sticky identity
fields (name + avatar) with a to_state(is_muted, addr, sharing) builder
that folds in the volatile per-announce fields, so the PeerState literal
lives in one place. This is the precondition for adding a broadcast
game-presence field without editing every call site.
No behavior change. +1 unit test (359 lib total path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Save-attachment handler called the blocking rfd::FileDialog::save_file()
directly inside iced's update() loop. That blocking dialog spins its own GTK
loop; invoked from within iced's already-running event loop (notably the Linux
xdg-desktop-portal/GTK backend, but also observed wedged on Windows) the dialog
becomes unresponsive — Save/Cancel clicks are never processed.
Convert to rfd::AsyncFileDialog returning a Task, mirroring the existing file
*picker* paths (PickAttachmentFile / PickAvatarFile / PickBackgroundFile) which
already use the async variant. The chosen path's bytes are written when the
future resolves; the status line is reported via a new AttachmentSaved message.
No blocking call remains in the update loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-stages the playback dependency for the inline chat audio player task so
Codex can build it in its network-off sandbox.
rodio 0.22.2 decodes wav/mp3/ogg(vorbis)/flac (via bundled symphonia) and
handles output + play/pause/seek + resampling. It brings its own cpal 0.17
(the project's PipeWire/cpal-0.15 call path is untouched; rodio's output is a
separate stream on the system default device) and alsa on Linux.
Supply chain: cargo audit reports NO new advisories from this subtree -- the
only 2 warnings (audiopus_sys, paste) are pre-existing, unmaintained-only, and
already on the allow-list. Builds clean (release).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chat file fetches failed 100% of the time with "file fetch: read failed:
read error: connection lost" (both images and arbitrary files, both
directions). Root cause: the FileRouter serve handler called send.finish()
and immediately returned Ok(()), which dropped the Connection. In QUIC,
finish() only marks the stream's EOF -- it does not wait for the written
bytes to be delivered and acknowledged -- so the connection's
CONNECTION_CLOSE raced ahead of the still-in-flight stream data and the
fetcher's read_to_end aborted.
Fix: after finishing, wait on connection.closed() (bounded by
FILE_FETCH_TIMEOUT) so the link stays up until the fetcher has read
everything and closed the connection itself, which is the signal the
transfer landed.
Wire-compatible (no protocol change), so version stays 0.3.0; both peers
just need the rebuilt binary since either side can be the file server.
Adds tests/file_transfer_loopback.rs: a real two-endpoint serve->fetch
round-trip over FILES_ALPN with a 2 MiB multi-packet blob (deterministic
A/B: 0/20 pass without the fix, 20/20 with it) plus an unknown-id "gone"
case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- INSTALL.md: bump the setup filename to 0.3.0; add an end-user section
on text chat + sending photos/files (inline images, file chips,
Save/Download, 25 MB cap, session-only); note that both ends must run
the same version under "won't connect".
- README.md: add a Version compatibility section (installer version
tracks Cargo; a 0.x MINOR bump is a breaking wire change so everyone
must reinstall; 0.3.0 can't talk to 0.2.x).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the Cargo 0.3.0 release (chat file sharing + per-peer gate). The
installer payload is unchanged (single self-contained peerspeak.exe +
icon); only the version string / output filename move to 0.3.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the send/receive paths and the chat UI on top of the file plane.
(Committed together because the UI renders the state the core wiring
produces.)
Core:
- CoreCommand::SendChatFile {text, attachment, data}: serve the bytes on
the file plane (serve_attachment) then broadcast the descriptor via
send_chat. CoreCommand::FetchAttachment {from, attachment}: detached
fetch -> AttachmentReady/AttachmentFailed.
- On an inbound Chat with an Image attachment, auto-fetch + defensively
re-validate (decodable + within pixel limits) before delivering;
non-images wait for an explicit fetch (the Save/Download chip).
- UiEvent::ChatMessage carries the attachment; new AttachmentReady /
AttachmentFailed events keyed by attachment id.
App:
- 📎 attach button + native picker; reads the file, enforces the size
cap, classifies image vs file, mints a random id, optimistically
echoes the message + caches our own bytes (so we see our own image
inline), and sends SendChatFile.
- Renders inline image thumbnails (handle cached by id to avoid the
per-redraw re-upload flicker), file chips with Save/Download, a
loading placeholder for in-flight images, and an error line on
failure. Image messages with no caption still render.
- SaveAttachment: saves immediately if bytes are in hand, else fetches
then saves when ready (pending_saves) via a native save dialog;
filename defaulted from the sanitized descriptor.
- Session-only: attachment bytes/handles cleared on leave, never
persisted.
Binary + clippy clean, 349 lib tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the dedicated FILES_ALPN data plane that moves attachment bytes
off-gossip via direct QUIC streams.
- FileRouter (ProtocolHandler on the persistent router, mirroring
AudioRouter): bound to the active session's Shared on join, cleared on
leave. On an inbound stream it authenticates the peer via the ALPN
handshake, gates on live room membership (reuses audio_sender_admitted,
so a former member cannot pull files), reads exactly one 32-byte
attachment id (bounded request read), and streams back the matching
blob from the session serve store — or an empty body for an unknown id.
- Shared gains served_files (id -> bytes), populated by serve_attachment
and cleared on leave.
- IrohTransport::serve_attachment + fetch_attachment (inherent methods;
transport is used concretely). fetch dials the sender on FILES_ALPN
(preferring a known full address), writes the id, and reads bounded by
the descriptor's declared size, with a 30s connect/read timeout so a
stalled sender can't hang the fetch.
- Register FILES_ALPN in the router; bind/clear file_router in lock-step
with audio_router at every join/leave site.
Builds + clippy clean, 349 lib tests pass (plane is runtime I/O,
field-tested in stage 5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of in-chat file/photo sharing (dedicated file plane, images
inline + file chips, session-only). This stage adds the wire types and
the pure, unit-tested logic; no transport or UI yet.
- protocol: new FILES_ALPN / FILES_PROTO (peerspeak/files/1) for the
dedicated file-transfer plane. Bump GOSSIP_PROTO 1->2 + sig domain v2
(Chat gained an attachment field, so cross-version peers fail fast
rather than half-work) and Cargo 0.2.0 -> 0.3.0 per VERSIONING.md.
BREAKING wire change: all peers must run >= 0.3.0.
- new src/files.rs: ChatAttachment descriptor (name/size/kind/id; bytes
travel off-gossip), AttachmentKind, plus pure seams — sanitize_filename
(path-traversal/control-char/length-safe), size_within_cap, image
magic-byte sniffing + defensive limited decode (decode-bomb guard),
32-byte request parsing, human_size. 13 unit tests.
- GossipMessage::Chat and RoomEvent::ChatMessage carry an optional
ChatAttachment; send_chat takes Option<ChatAttachment>. Untrusted
inbound descriptors are filename-sanitized + size-validated on ingest.
serde(default) keeps the field forward-compatible at the JSON layer;
+round-trip and pre-v2 back-compat tests.
The attachment id is a random 32-byte handle (rand, already a dep), not
a content hash — the fetch is authenticated + encrypted + member-gated,
so no crypto-hash dep is needed.
349 lib tests pass, clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Let a listener apply a noise gate to an individual peer's incoming
audio — "fix this person's noisy mic / background hum on my end" —
which is only possible because full-mesh P2P keeps every peer's stream
unmixed locally (server-mixed apps can't do per-listener per-peer DSP).
The DSP is the existing mic NoiseGate reused verbatim: it already
processes i16 frames at a fixed rate with hysteresis/attack/release/
hangover and takes the threshold per-frame. Wiring mirrors per-peer EQ:
- AppConfig.peer_gate map (threshold per peer id; absent/0 = off),
persisted, never sent over the wire
- CoreCommand::SetPeerGate + Arc<Mutex<HashMap>> shared into the mixer
- a live HashMap<EndpointId, NoiseGate> in the mixer task, created
lazily and dropped when disabled (no rebuild needed — threshold is
passed per frame)
- Gate row (threshold slider, "Off" at zero) in each participant card
next to Vol/Pan/EQ, persisting on release
The gate runs on the raw decoded frame: after the clean multitrack stem
tap (recordings stay ungated) but before volume/EQ, so the threshold
tracks the peer's true signal level regardless of our volume setting.
Same 0..METER_MAX scale as the mic gate.
+2 unit tests (config helper); +1 config back-compat assertion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-peer volume slider had no .step(), so iced's default step of
1.0 on a 0.0..=2.0 range meant it could only snap to 0%, 100%, or
200% — it felt like hard-left/hard-right only. Add .step(0.01) for
smooth 1%-increment control (matching the Pan slider below it, which
already set its own step).
Also persist per-peer volume across sessions, mirroring peer_pan/peer_eq:
- new AppConfig.peer_volume map (keyed by peer id string, serde default
for back-compat; never sent over the wire)
- replace the in-memory peer_volumes map with config-backed storage via
a new set_peer_volume_config helper (clamps to range, drops at-unity
entries so the config stays tidy)
- replay saved volumes to core on startup alongside pan/eq
- the slider writes to disk on release (AppMessage::PersistConfig)
+1 unit test for the config helper; +1 config back-compat assertion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-technical walkthrough to send alongside the installer: covers the
SmartScreen "unknown publisher" warning, the firewall/desktop-shortcut
checkboxes, and joining/creating a call via room tickets. Uses the actual UI
labels (Join Room / Create New Room / Copy Ticket / Leave Room).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package PeerSpeak for Windows as a single self-contained binary. The GUI
icon, notification chimes, and avatar presets are already embedded via
include_bytes!, and the cross-compiled .exe is statically linked (no extra
DLLs), so the installer payload is just peerspeak.exe plus an .ico.
- src/main.rs: set windows_subsystem = "windows" for release builds so the
GUI launches without a stray console window (debug keeps the console for
stderr/panics).
- packaging/windows/: Inno Setup script (peerspeak.iss), multi-resolution
app icon (peerspeak.ico), and a build README. The installer drops a
Start-menu/desktop shortcut, optionally adds a Windows Firewall allow-rule
(iroh UDP hole-punching), and provides an uninstaller.
- win-cross-build.sh: promote the cross-build helper from a throwaway to the
documented installer build step; .gitignore the staged exe + compiled
setup.exe build artifacts.
Built with Inno Setup 6.7.1 under Wine; binary is unsigned (SmartScreen will
warn until code-signed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the field-observed dead-end where a network outage longer than the
45s anti-flap grace evicted the peer with no path back (required manual
Leave + re-ticket). On grace expiry the peer is now torn down cleanly and a
bounded per-session recovery coordinator re-bootstraps the gossip overlay
via GossipSender::join_peers on retained authenticated addresses, with
immediate-then-1/2/4/8/15/30/60s capped backoff. Readmission still requires
a fresh authenticated signed Announce, preserving the S8/S11 membership
boundary; a transport link alone cannot readmit a grace-expired peer.
Field-verified 2026-06-20 on a 2-machine Linux-host <-> Windows-VM call
through a 93s link outage on the libvirt NAT path: both UIs auto-recovered
to "2 in room" with no manual Leave/Join, exactly one Reconnected chime,
host showed "reconnecting" during the outage, and the VM log captured the
full sequence (grace expiry -> rebootstrap 1->2->4 backoff -> NeighborUp ->
authenticated Announce -> readmit -> audio link up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the native Windows audio backend to main after a live host<->VM smoke
test: cpal/WASAPI capture+playback, device remap/resampling (W4/B5), cpal
RT-audit closed (B1-B5 + P3), Windows notification chimes, and Wine startup fix.
Verified on real Win11 (libvirt VM) this session: 2-way audio (host<->VM both
directions), audible join/leave/reconnect chimes, GUI renders, echo-cancel
correctly gated off. Linux unchanged (all changes cfg(windows); cargo test --lib
326/0, clippy clean). Windows build is GNU cross-compiled (b0fdd4e tester zip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One `makepkg -si` from packaging/test-pack/ builds and installs both
peerspeak and pixelpass from the public gitbutter repos over https, so a
tester can clone the repo and get a working voice+screenshare pair in one
command. pixelpass installs to /usr/bin so peerspeak's screen-share button
finds it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex's xhigh re-review of 306bc29 confirmed B3 sound and bounded_rate
correct (no P1/P2), and caught one real P3: choose_config ranked supported
config ranges by sample rate + channel count only, but the stream builders
accept just F32/I16/U16 — cpal can also expose U8/I8/I32/U32/I64/U64/F64.
An unsupported-format range (or a zero-channel range) could therefore out-
rank a usable one, win selection, and then hard-fail in setup()'s
`other => Err(unsupported sample format)` arm without trying another
candidate. This was latent in the exact-48 kHz path too, not only B5's
bounded case 3.
Fix: a pure `format_supported` predicate + `usable_range` (nonzero channels
AND a drivable format), applied as a filter in BOTH the exact-48 kHz `pick`
and the bounded `pick_bounded`, so an undrivable range is never ranked. A
zero-channel range can no longer be logged as "using bounded …" and then
rejected by resolve. +1 unit test enumerating every cpal SampleFormat.
Verified: windows-gnu cargo check --release --lib --tests --bins clean, no
warnings; Linux paths untouched (cfg(windows)).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the two Windows-only follow-ups Codex deferred in the RT-audit
re-review (review-2026-06-19-cpal-rt-audit.md). Both are cfg(windows),
so they carry zero risk to the shared Linux audio path.
B3 — orphan-thread tombstone on a wedged start. On the FINISH_START_TIMEOUT
path the owner thread is detached (not joined) so start_*/stop can't hang;
previously the slot was left empty, so a retry against a permanently wedged
device spawned ANOTHER orphan worker holding its own COM/device handle, and
so on without bound. The slot is now a SlotState { Idle | Live | Wedged }:
- Each worker carries an `exited: Arc<AtomicBool>` flipped true by an
ExitGuard at the top of the thread body — fires on normal return, panic
unwind, or whenever the wedged driver call finally releases the thread.
- A timed-out start detaches its thread and leaves a `Wedged { exited }`
tombstone instead of an empty slot.
- `ensure_idle` (pure, unit-tested) rejects new starts while the orphan is
still alive, but clears the tombstone once `exited` flips, so the slot
becomes reusable after the device recovers. `stop` restores a still-live
tombstone rather than silently clearing it.
B5 — choose_config picks a bounded supported rate before the device default.
A device whose default rate is outside the drivable 8k–384k window but which
also exposes a usable in-window config was previously rejected by resolve().
New case 3 scans the supported config ranges for one overlapping the window
and drives it at a `bounded_rate` (48 kHz when reachable, else the nearest
in-window bound), preferring the native layout; the device default is now a
last resort. `bounded_rate` is pure and unit-tested.
6 new unit tests (bounded_rate x4, ensure_idle x2) — they're in the
cfg(windows) module, so they compile/run under the windows-gnu target, not
the Linux lib suite.
Verified: Linux cargo test --lib 326/0 + clippy --lib --tests clean (shared
paths untouched); windows-gnu cargo check --release --lib --tests --bins
clean, no warnings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex's xhigh re-review of the prior cpal RT fixes confirmed W2/W3/W7/W4-diag
addressed (and validated the reserve-first ring-publish ordering), but found the
W1/W6 start-handshake fixes were partial. This closes the holes:
- B1 (P1): wait_for_stream_start checked the liveness flag before the error code,
so a callback that ran then failed in the same WASAPI cycle could still report
Ok on a dead stream. Readiness now (a) treats the error as terminal — checked
first each loop AND re-checked before returning Ok — and (b) requires
MIN_START_CALLBACKS (2) completed callbacks, not one, so a fire-once-then-die
stream is caught by the error/timeout path. The liveness signal is now a
callback counter (AtomicUsize) instead of a one-shot bool.
- B2 (P2): on the inner STREAM_START_TIMEOUT the owner sent Err and THEN dropped
the stream; since cpal Stream::drop joins its (wedged) WASAPI worker and
finish_start joins the owner on that Err, start_*/stop could still hang past the
backstop. The owner now drops the stream BEFORE reporting Err, so a wedged drop
withholds the Err and lets finish_start's timeout branch detach.
- B4 (P3): the two timeouts didn't compose — a slow-but-valid setup plus a slow
first callback could exceed the 6s backstop and be falsely failed. Raised
FINISH_START_TIMEOUT to 10s (setup budget + callback wait + cleanup slack) and
corrected the comment.
Deferred (logged in review-2026-06-19-cpal-rt-audit.md): B3 (orphan-thread
tombstone accounting on a permanent >10s driver wedge — rare, non-crashing, needs
a slot-state redesign) and B5 (choose_config picking a bounded supported rate for
an oddball sub-8k/over-384k default-rate device — rare; the safety validation
already prevents the panic/spin).
Verified: Linux cargo test --lib 326/0, clippy --all-targets clean; windows-gnu
cargo check --lib --tests --bins clean; windows-gnu release peerspeak.exe links.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses Codex's xhigh RT-audio audit of the new Windows cpal path (review
2026-06-19; all Windows-only, no Linux-path change):
- W1 (P1): start_capture/start_playback reported Ok as soon as cpal's play()
returned, but cpal's WASAPI play() only QUEUES IAudioClient::Start(); a later
Start failure left the UI joined-but-silent. Readiness is now driven by the
stream actually proving itself: the first RT data callback sets a started
flag (or the error callback sets an error code), and the owner thread waits
(bounded by STREAM_START_TIMEOUT) before reporting Ok.
- W2: both RT error callbacks ran format!+log_msg on the time-critical stream
thread. They now store a category in an AtomicU8 only; the owner / health
logger translate + log off the RT path.
- W3: the playback ring was published one interleaved sample at a time, letting
the RT consumer read a half-written L/R pair and letting a raced fetch_sub
wrap ring_fill to usize::MAX (wedging mixer pacing). Now reserves occupancy
before publishing and writes the whole frame with a single push_slice.
- W6: finish_start did an unbounded recv() while holding the slot mutex, so a
wedged driver hung start_* and any concurrent stop. Now recv_timeout with a
FINISH_START_TIMEOUT backstop; on timeout it signals + detaches (never joins).
- W7: OS-reported device geometry is validated in resolve() (channels>0, rate in
8k-384k) so 0 channels can't panic chunks_exact(0) and a 0/absurd rate can't
make an infinite/huge resample ratio. resample.rs constructors also clamp
rates >=1 (release-safe; +2 tests) instead of a debug-only assert.
- W4 (diagnostic half): the playout-health logger compared raw device samples
against the internal-stereo prefill target. The callback now records demand in
internal 48 kHz-stereo units (internal_demand) so the comparison is correct
for remapped/non-48k devices. The dynamic-target restructure stays deferred.
Deferred (logged in review-2026-06-19-cpal-rt-audit.md): W5 (bounded mixer->
worker channel) touches the shared Linux audio path and wants its own design +
regression pass; the W2 dynamic-target sizing needs a real WASAPI callback.
Verified: Linux cargo test --lib 326/0, clippy --all-targets clean; windows-gnu
cargo check --lib --tests --bins clean; windows-gnu release peerspeak.exe builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GUI re-sends the saved network mode as part of its startup config-sync.
The SetNetworkMode handler unconditionally tore down + rebuilt the iroh
endpoint whenever idle, so every launch rebuilt the freshly-built stack for
an identical posture — a needless ~1s teardown+rebuild bounce visible in the
logs on both Linux and Windows/Wine (the 'start core loop -> shut down network
stack ~1s later' pattern from the Wine spike). Guard the rebuild on an actual
mode change; a real change still rebuilds exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/audio/resample.rs: pure linear PushResampler (capture) +
StereoPullResampler (playback pull), 6 unit tests green on Linux.
- choose_config: prefer native 48kHz, else fall back to device default
config and convert at the boundary instead of hard-erroring.
- run_capture: resample device-rate mono -> 48kHz on the drain thread.
- i16<->f32 helpers. Playback build_output remap still TODO (Codex).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>