Phase 3 of docs/chat-hardening-plan.md — attachments can no longer turn
into unbounded memory, bandwidth, decoder, or task pressure (S15 closed;
S14's filename half closed).
Cache and image cost (3A): AttachmentCache now carries encoded- and
decoded-byte budgets (96 MiB / 64 MiB) on top of the count cap, with
per-entry weights, replacement accounting, and oldest-first eviction; an
individually over-budget fetch services any pending Save/Play from the
bytes in hand and is exposed as Evicted instead of retained.
validate_image_bytes prechecks header dimensions (per-side AND a new
14 MP total-pixel limit) before any decode; the renderer only ever
receives a ≤1600 px downscaled RGBA preview whose w*h*4 cost counts
against the decoded budget — originals stay encoded-only for Save.
sanitize_filename strips the bidi/zero-width spoofing set (RTL-override
extension spoof).
Download policy and state (3B): images auto-fetch only when roster-
authored AND declared ≤4 MiB, gated by a new deterministic
AutoFetchBudget (per-author and session request+byte token buckets,
check-then-take, bounded author map) alongside the existing dedup and
four-permit bound. Attachment state is now explicit — absence/Loading/
Ready/Failed/Evicted — driven by a new AttachmentFetchStarted event, so
skipped or evicted images render a "Load image" button instead of an
indefinite "loading…", and repeated clicks can never spawn duplicate
fetch tasks.
Exact transfers and serve store (3C): fetch_blob requires the received
length to equal the declared size (short = local error, overlong =
bounded-read reject, empty keeps meaning "sender no longer has it");
the file picker's unbounded read is replaced by a metadata-prechecked
cap+1 bounded reader; one Arc<Vec<u8>> now backs the UI cache, command
queue, and serve store; served_files is a count- and byte-budgeted FIFO
ServeStore (16 entries / 128 MiB).
37 new tests (568 lib total) including a real two-endpoint loopback
exercising exact/short/overlong/unknown-id transfers. Plan checkboxes
ticked and constant deviations decision-logged. Tests-green-only: the
plan's two-machine field-test section remains open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Answer "am I actually P2P right now?" per peer. A 1 Hz session task
snapshots the selected QUIC path of every live audio connection
(IrohTransport::connection_stats), core::connstats::derive turns
consecutive snapshots into RTT/loss/bitrate (path switches and counter
resets invalidate the rate window), and the peer card shows a
Direct/Relay badge with a hover tooltip for address, loss, and up/down
bitrate. No new dependencies, no wire change.
Loopback-integration-tested against real iroh endpoints; not yet
field-verified on a 2-machine call (FEATURES.md row marked 🧪).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Personal playlist on a dedicated music player (browse/play/prev/next/
seek/volume/reorder/remove, .pls/.m3u import), plus per-person shared
listening: broadcast your track over presence, peers tune in and stream
it point-to-point over the files plane. Playback is timeline-synced
(play/pause/skip/seek mirror with no drift) with gapless prefetch of the
next track and independent per-source volume per listener.
In the 3-column layout the playlist gets its own card stacked under the
chat, with a resizable divider and its own scrollbar; other layouts keep
it in the Controls panel.
Breaking wire change: gossip protocol v5 (presence gains music fields),
so 0.6.0 peers cannot share a swarm with 0.5.x. Version bumped 0.5.1 ->
0.6.0; CHANGELOG updated.
Untrusted-input handling: broadcast track name sanitized and size
cap-checked at gossip ingest, fetched bytes confirmed audio before
decode, only the descriptor rides gossip (bytes go point-to-point, one
fetch in flight).
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>
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>
Establishes VERSIONING.md: SemVer 0.x (MINOR = breaking wire change) for the
release version, and per-plane protocol versions enforced on the wire so
incompatible peers fail fast and legibly instead of via silent decode/signature
errors.
⚠️ BREAKING WIRE CHANGE — all peers must run >= 0.2.0 to interoperate (ALPNs and
gossip subscription topics changed). A pre-0.2.0 peer (e.g. an un-resynced
dopedart) can no longer connect, by design, and now fails at the handshake.
- New src/protocol.rs: single source of truth for AUDIO/FRIENDS/GOSSIP_PROTO,
the derived ALPNs (peerspeak/audio/1, peerspeak/friends/1), GOSSIP_SIG_DOMAIN,
and versioned_topic(). Unit tests assert ALPN/domain strings match their
integer versions (no silent drift) + that topic namespacing is deterministic.
- Unified ALPNs: audio was b"peerspeak-audio" (unversioned, and duplicated in
iroh_impl.rs + core/mod.rs) -> peerspeak/audio/1 from protocol.rs; friends
re-exports protocol::FRIENDS_ALPN (was peerspeak/friends/0 -> /1).
- Gossip: subscribe to versioned_topic(ticket.topic_id) so different gossip
versions never share a swarm; the raw topic_id stays the room identity and
what signatures bind. GOSSIP_SIG_DOMAIN centralized into protocol.rs.
- Cargo.toml 0.1.0 -> 0.2.0.
316 lib tests / clippy --all-targets clean. VERSIONING.md documents the bump
rules, the "I changed X -> what do I bump" table, and a release checklist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes S8 (High): inbound audio was authenticated by identity (remote_id) but
NOT by room membership, so a former member who knew a current member's endpoint
could reconnect on the audio ALPN and inject into / eavesdrop on the mix while
invisible in the roster. Now audio is admitted only for live gossip-roster
members (the senior+user-resolved GRACE-AWARE policy).
Transport (src/network/iroh_impl.rs):
- New per-session admitted_audio: HashSet<EndpointId> on Shared (internal state,
no wire/serialization change). Cleared on disconnect_all.
- AudioRouter::accept consults audio_sender_admitted BEFORE ensure_supervisor —
a non-member never gets a supervisor, sender handle, datagram reader, or
outbound mix. Brief StdMutex check, released before the await (no RT lock).
- Pure apply_audio_admission_event(roster, peer, event) with AudioAdmissionEvent
{RosterPresent insert, TransientDropGrace no-op, Remove}. Grace deliberately
cannot ADD membership — it only preserves an already-admitted peer — so an
unknown peer can't sneak in via a grace event. +3 lifecycle tests (on top of
Pass-1's 4 predicate tests).
- admit/keep_for_reconnect_grace/remove/query methods for core to drive.
Core (src/core/mod.rs) — authority is core's VERIFIED gossip-roster events, not
transport connect/disconnect:
- PeerJoined / PeerUpdated: admit_audio_sender before connect_peer.
- PeerConnectionLost: keep_audio_sender_for_reconnect_grace (preserve through the
existing RECONNECT_GRACE window — no audio cut on transient blips).
- gossip PeerLeft, transport ConnEvent::Left, grace-timer expiry: remove_audio_sender
before disconnect_peer + jitter removal (removal-before-teardown bounds the
in-flight-datagram race).
- datagram receiver: audio_sender_admitted gate before any jitter buffer (defense
in depth against a datagram racing a removal). Mixer stays off the hot path.
Mid-join: a peer who dials audio before we've verified their signed Announce is
dropped (no "pending" admission, which would reintroduce the eavesdrop); their
reconnect loop recovers once the Announce admits them.
tests/transport_loopback.rs: admit both ends before connecting, mirroring the
production room-event order.
313 lib / clippy --all-targets / transport_loopback 4 / reconnect_eviction 6 /
release — all re-run green by the senior. Former-member-rejection + mid-join
recovery are verifiable only in a 2-machine call (senior's to run).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design-first checkpoint for S8 (authorize inbound audio against live room
membership). Codex's design note (in the handoff task-report.md) establishes
the authoritative roster = gossip IrohGossipState.peers, NOT the audio
transport connection list, and recommends mirroring it into an audio-admission
snapshot consulted at AudioRouter::accept + datagram ingest.
This commit lands ONLY the pure decision seam + tests; wiring is deliberately
paused for a senior decision on the reconnect-grace policy (gossip drops a peer
from the roster on transient NeighborDown, but core keeps the audio supervisor
alive for RECONNECT_GRACE — a strict roster-only gate would cut audio on blips).
- audio_sender_admitted(remote, roster) -> bool (pub(crate), #[allow(dead_code)]).
- 4 tests: member admitted, stranger rejected, former member rejected after
roster removal, mid-join peer rejected until authenticated Announce inserts it.
- No behavior change: accept/datagram/mixer paths untouched. S8 remains OPEN.
310 lib tests / clippy --all-targets / release all green (re-run by senior).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The friends-only presence listener (W7) must answer pings while the app is
open, whether or not we're in a call — but a node id has exactly one live
endpoint instance (proven by the dual-endpoint spike: two endpoints sharing a
SecretKey collide, all inbound connections land on one and the other's ALPN
fails the QUIC handshake). So the listener can't get its own endpoint; the
whole app must share one persistent endpoint. Today the core rebuilds the
endpoint+gossip+router on every Join and tears them down on leave, so there's
nothing alive between calls.
B1 hoists those durable pieces to the app lifetime (no new behavior):
- New persistent `NetStack` (endpoint + gossip + Router) built once at startup
under the RelayNoDiscovery default (relay reachability, no DNS beacon);
`online()` is backgrounded so launch isn't blocked.
- New persistent `AudioRouter` (src/network/iroh_impl.rs) replaces the
per-session `AudioProtocol`: it's registered once on the single Router and
delegates each inbound audio connection to whatever session `Shared` is bound
(`bind` on join, `clear` on leave), dropping links when idle. `IrohTransport::
new` now returns just `Self`.
- Join reuses `net.endpoint`/`net.gossip` and only subscribes its gossip topic +
binds the audio router; Leave clears the router but keeps the endpoint up.
- `SetNetworkMode`/`RegenerateIdentity` rebuild the stack immediately when idle,
else defer to the next Leave/Join (preserves "applies on next join"), and the
existing session is always torn down before any rebuild closes the endpoint.
Tests/loopback updated for the new transport API. 256 lib + 6 reconnect + 4
loopback + 2 ignored real-endpoint tests green, clippy --all-targets clean,
release builds. NOT yet 2-machine field-verified — that regression is the gate
before this merges to main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field-testing the reconnect fix surfaced a follow-on bug: clicking Disconnect
(or quitting) showed the peer as yellow "Reconnecting" on the other end instead
of leaving cleanly. Logs showed the remote saw NeighborDown first and the gossip
`Leave` arrived ~15s later (broadcast then router torn down ~450ms after, so
NeighborDown — now meaning "reconnecting" — beats the slow, swarm-routed Leave).
Fix: make graceful leave a prompt, reliable TRANSPORT signal instead of relying
on gossip. On leave/quit, IrohTransport::leave() explicitly closes each live
connection with a distinguished goodbye code (GOODBYE_CODE) and aborts all
supervisors (so none linger redialing the about-to-close endpoint). The remote's
supervisor inspects the close reason: an application close with our goodbye code
=> emit the new ConnEvent::Left (evict now); a timeout/reset/other code =>
transient drop, reconnect as before. core handles ConnEvent::Left exactly like a
PeerLeft (cancel grace timer, disconnect, drop jitter, UI remove). ActiveSession
now holds the transport and calls leave() before shutting the router down.
Tracks the live connection per peer in Shared.live_conns (inserted on link up,
removed on drop) so leave() can close them. The gossip Leave path stays as a
harmless backup.
New test dialer_reports_left_on_graceful_close: a close with the goodbye code
reports Left and does NOT reconnect (mirror of the code-0 transient-drop test).
clippy clean, 4 transport tests pass. NOT yet field-verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dialer-side supervisor dialed peers by bare EndpointId, forcing iroh to
resolve the address through the gossip-fed MemoryLookup. A transient drop that
also triggered a gossip Leave/NeighborDown purged the peer from that lookup, so
the supervisor redialed forever with "no address" (observed in the 2026-05-30
field test: ~4.5 min of "No address lookup configured; retrying in 5s").
The transport now retains each peer's full EndpointAddr (relay + direct addrs),
refreshed on join and on every re-announce (so a rejoin on a new address updates
the dial target), and the dialer dials that retained address directly — which
bypasses the lookup entirely. connect_peer now takes an EndpointAddr; core hands
over state.addr on PeerJoined and PeerUpdated.
New regression test dials and reconnects with the dialer's lookup deliberately
empty and the relay disabled, so only the retained address can carry the dial:
it passes with the fix and times out under the old bare-id behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audio links now survive a dropped QUIC connection instead of silently
dying until the peer leaves and rejoins the room.
Transport: each peer is owned by a single supervisor task running a
connect → run → reconnect loop. The deterministic-initiator rule (lower
id dials, higher accepts) holds on every reconnect, so one shared
connection re-forms each time; the dialer redials with capped backoff
and the acceptor awaits the inbound link, switching to a replacement
immediately if one arrives before its own close fires. Inbound
connections are routed to the supervisor via a per-peer channel.
Fixes a latent bug from the prior design: aborting a peer's tasks left
the detached send/read loops running, holding Connection clones so the
link never actually closed. The loops now live in abort-on-drop guards
scoped to the supervisor, so cancelling it releases the connection.
UX: a new ConnEvent stream surfaces per-peer link state to the UI, which
shows "Connecting…"/"Reconnecting…" with a yellow indicator and border
while a peer's audio link is down, returning to normal when it recovers.
Tests: the loopback test now runs through the supervisor path, plus a
new test drives a real drop (explicit close of a controlled peer
endpoint) and asserts the dialer re-dials the stable address and audio
resumes over the rebuilt connection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the fire-and-forget datagram path with sequenced packets, a
per-peer jitter buffer, and persistent per-peer send tasks. Together these
fix three intertwined weaknesses that only showed up off localhost.
Packet format: every audio frame now carries a 4-byte little-endian
sequence number header ([seq][opus payload]), the basis for reordering and
loss detection.
Jitter buffer (core/jitter.rs): incoming packets are reordered by sequence
behind a fixed ~60ms playout delay. Missing sequences with later packets
already buffered are concealed via Opus PLC (decode(None)) -- a path the
decoder supported but nothing ever invoked. Underruns go idle and re-buffer
rather than concealing indefinitely. Covered by unit tests using real
encoded frames (reorder, gap-conceal, prime, late-drop).
Transport (network/iroh_impl.rs): each peer gets one long-lived send task
fed by a shallow bounded channel (drop-oldest on backpressure), instead of
spawning a throwaway task per peer per 20ms frame. Connections are now
established reactively on peer-join and torn down on peer-leave; the
lexicographically-lower EndpointId dials so a full-mesh pair forms exactly
one shared bidirectional connection instead of two racing ones. This also
removes the previous lock-held-across-connect().await serialization.
Opus decoder: PLC output is now sized to one 20ms frame, so concealment
synthesizes 20ms instead of a 120ms burst from the oversized max buffer.
Known follow-up (Tier 2): no reconnect on transient connection loss; a
send error currently retires the peer until they rejoin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Remove Gemini's committed update_*.py regex-surgery scripts
- Drop unused iroh-tickets dependency (hand-rolled ticket is used instead)
- Replace ToString antipattern with Display impl on PeerSpeakTicket
- Route debug log to XDG state/cache dir instead of hardcoded /home path
- Clear all compiler + clippy warnings (unused imports, collapsible ifs,
redundant pattern matching, missing Default)
Builds clean with zero warnings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>