Commit Graph

12542 Commits

Author SHA1 Message Date
Claude 951f6c37f0 refactor(nests): extract action bar affordances and refresh KDoc
NestActionBar.kt had grown to ~470 lines with most of the volume in
OnStageControls, where four broadcast-state branches each inlined a
56dp filled icon button, a tonal toggle button, and an outlined
"Leave the Stage" button. The result was hard to scan for state
coverage, and the top-level KDoc still described the pre-cleanup
layout.

Changes:

* Rewrote the top-level KDoc as a state-by-state table matching the
  current UI.
* Extracted reusable affordances:
  ConnectButton, StatusChip, LeaveStageButton, TalkButton,
  StopBroadcastButton, MicMuteToggle, HandRaiseToggle,
  LeaveRoomButton.
* Merged the duplicate Connect button branches in StartCluster
  (Idle / Closed / Failed all share one). The failure reason already
  appears in the status strip above.
* Split OnStageControls: kept the dispatcher thin and moved the
  permission state + Settings deep-link into OnStageIdleControls,
  which is the only state that needs them.
* Replaced the inline qualified Settings Intent with a
  Context.openAppSettings() helper, plus a Context.hasMicPermission()
  helper for the two RECORD_AUDIO checks.
* Pulled the status-strip text resolution into a small
  NestUiState.statusStripText() so ActionBarStatusStrip is one Text.

No behavior changes — just structure, naming, and docs.
2026-04-30 19:22:52 +00:00
Claude 7be8ac5106 fix(nests): drop redundant Live chip while broadcasting
The red Mic-on FilledIconButton already conveys "you are live, tap
to stop" — the AssistChip labelled "Live" beside it was duplicate
visual noise. Removed the chip and its now-orphan string. Mute toggle
+ red Mic + Leave the Stage are sufficient to express the broadcasting
state.
2026-04-30 19:14:17 +00:00
Claude 640e7904e4 fix(nests): tighten action bar state coverage
Several gaps in the action bar were causing dead-end UI states or
making non-actionable controls visible:

* Hand-raise was rendered while disconnected / connecting / failed.
  Raising can't be delivered to the room until we're Connected, so
  hide it until the connection state agrees. Threaded `isConnected`
  into `EndCluster` from the root composable.
* Audience listen-mute toggle (Volume Up/Off) removed. System volume
  keys cover local volume; the on-screen control was redundant and
  shared the same speaker glyph as the broadcasting mic-mute toggle,
  which made the two affordances easy to confuse.
* On-stage user without `canBroadcast` had no way to step down without
  leaving the whole room. Show a "Leave the Stage" outlined button
  in that branch so the speaker slot can be released.
* Broadcast `Connecting` and `Failed` substates also had no
  step-down affordance — forcing the user to either retry the mic or
  leave the room. Added "Leave the Stage" to both. `stopBroadcast()`
  cancels the in-flight `speakerConnectJob` (NestViewModel
  `teardownBroadcast`), so cancelling mid-handshake is safe.
* `BroadcastUiState.Broadcasting.muteError` was tracked in state but
  never surfaced. Route it through `ActionBarStatusStrip`.
* Permission denial pill said "Open settings"; renamed to
  "No permissions" so the label communicates the *state* (the tap
  still deep-links to the system settings page).
2026-04-30 19:02:25 +00:00
Claude b86219b1e1 fix(nests): match mute toggle height with action bar buttons
Material3 expressive defaults the FilledTonalIconToggleButton container
shorter than ButtonDefaults.MinHeight, leaving the audience listen-mute
and on-stage mic-mute toggles visibly shorter than the neighboring
"Leave the Stage" / "Leave" outlined buttons. Pin the toggle height to
ButtonDefaults.MinHeight so the row aligns.
2026-04-30 17:57:45 +00:00
Vitor Pamplona 3d46dfcfe1 Merge pull request #2663 from vitorpamplona/claude/fix-nest-room-crash-KJwBQ
Support Android's hostname-aware certificate validation
2026-04-30 12:16:25 -04:00
Claude a6605e2792 fix(quic): use hostname-aware trust manager on Android
Android's RootTrustManager throws when an app has Network Security Config
domain-specific entries and the 2-arg checkServerTrusted overload is used,
crashing Nest room joins on relays covered by such config. Discover the
3-arg checkServerTrusted(chain, authType, hostname) overload via reflection
and invoke it with the SNI host; fall back to the standard 2-arg form on
plain JVM where that overload doesn't exist.
2026-04-30 16:10:00 +00:00
Vitor Pamplona d91ffb6f32 Merge pull request #2662 from vitorpamplona/claude/fix-expiration-test-sqlite-6Vv10
Fix expiration timing in ExpirationTest
2026-04-30 11:57:15 -04:00
Claude 8a349d0f2e fix(quartz): widen ExpirationTest insert window to dodge isExpired race
The pre-check in SQLiteEventStore.insertEvent rejects events whose
expiration is <= TimeUtils.now(). With expiration = time + 1, the
wall clock can tick past time + 1 between sampling `time` and the
insert call, intermittently failing testDeletingExpiredEvents.

https://claude.ai/code/session_01GoqXtcFnwgyjBict1EURyA
2026-04-30 15:36:05 +00:00
Vitor Pamplona 7a9896a788 Merge pull request #2661 from vitorpamplona/claude/fix-nests-speaker-test-601LZ
Fix race condition in ReconnectingNestsSpeakerTest broadcast handle access
2026-04-30 10:38:50 -04:00
Claude dd9a530305 fix(nests): publish handles list under AtomicInteger barrier in speaker test
ScriptedSpeaker mutated `handles` AFTER incrementing `_startCount`,
so a reader that polled startCount on a different thread (the test
runs on the runBlocking thread while the broadcast pump runs on
Dispatchers.Default) could see startCount==1 via the volatile
AtomicInteger read before the subsequent list write became visible,
then fail `assertEquals(1, first.handles.size)` with a stale 0.

Also `mutableListOf` itself is not thread-safe — concurrent reads
during a write are undefined.

Reorder so the list append happens BEFORE the increment (the
volatile write now publishes the list mutation under the same
happens-before edge tests already rely on for startCount), and
swap the backing store for CopyOnWriteArrayList so concurrent
reads of `handles[0]` are well-defined.

Observed as a flake on Ubuntu CI; passes locally.
2026-04-30 14:34:53 +00:00
Vitor Pamplona 5a81171b58 Merge pull request #2660 from vitorpamplona/claude/fix-feed-icons-activation-Qxdem
Preload bottom bar feeds reactively based on user's pinned items
2026-04-30 10:30:13 -04:00
Vitor Pamplona 79e4a841e7 Merge pull request #2653 from vitorpamplona/claude/filter-empty-rooms-jRDGd
Improve Nests feed filtering to check for active speakers
2026-04-30 10:25:36 -04:00
Claude 612f2aa31e fix(nests): prune stale presence entries from LiveActivitiesChannel
Presence entries are valid for ~10 min (PRESENCE_FRESHNESS_WINDOW_SECONDS in
NestsFeedFilter). Without explicit cleanup, presenceNotes would grow unbounded:
every author who ever heartbeats in a room leaves an entry there forever.
The freshness check kept the filter result correct, but memory only ever grew.

Add LiveActivitiesChannel.pruneStalePresence(cutoff) and call it from
LocalCache.pruneOldMessagesChannel alongside the existing notes prune. Cutoff is
2x the freshness window (20 min) so a presence still inside any feed's window
can never be reaped.
2026-04-30 14:19:21 +00:00
Claude 566133750b refactor(nests): keep room presence out of channel.notes entirely
Presence (kind-10312) was being stored in both `channel.notes` and the
`presenceNotes` index. The mixed-kind `notes` map is dominated by chat
in active rooms, and only HomeLiveFilter still read presence from it --
which is now migrated to scan presenceNotes directly.

- LocalCache.consume(MeetingRoomPresenceEvent): drop the `channel.addNote`
  call; only addPresenceNote, plus addRelay so the channel's relay-counter
  still tracks where presence arrived from.
- LiveActivitiesChannel: addPresenceNote / removePresenceNote emit on
  flowSet.notes so reactive observers (NestsFeedLoaded) still update.
- HomeLiveFilter.shouldIncludeChannel: scan presenceNotes separately for
  follow-broadcast detection in audio rooms (chat scan unchanged).
- HomeLiveFilter.followsThatParticipateOn: also count presenceNotes
  authors so audio-room hosts/speakers factor into the participation
  sort even when they haven't chatted.
- ChannelFeedFilter: delete the isChatEvent workaround that was excluding
  presence from the chat feed -- presence no longer lands there.
2026-04-30 14:19:21 +00:00
Claude 7845072f20 fix(nests): evict author from prior room when presence moves to a new room
kind-10312 is replaceable per author, but the room a presence points to
(`a`-tag) can change when a speaker hops between rooms. The replaceable
cache only swaps the addressable's content -- it doesn't know which channel
the old version was attached to, so the prior room kept the stale entry in
both `notes` and the new `presenceNotes` index. NestsFeedFilter would then
falsely surface the prior room as "live" via that author until the entry
dropped out of the freshness window.

Capture the prior room from the existing addressable before consumeBaseReplaceable
swaps it. When the new event is a true replacement (createdAt > prior) and
the room differs, drop the author from the prior channel's presenceNotes
and remove the prior version note from its main notes index.
2026-04-30 14:18:37 +00:00
Claude d87fc36d21 perf(nests): index room presence separately from chat for O(speakers) scans
LiveActivitiesChannel.notes is mixed-kind (chat, zaps, raids, clips, presence),
and chat dominates by volume in active rooms. The Nests feed filter scanned it
twice per room per recompute -- once for any-fresh-presence, once for fresh
on-stage presence -- doing an `is MeetingRoomPresenceEvent` cast on every chat
message just to find the speakers.

Add a presenceNotes index on LiveActivitiesChannel keyed by author pubkey
(presence is replaceable per author, so the key auto-collapses heartbeats).
Populate it from LocalCache.consume(MeetingRoomPresenceEvent). Switch
NestsFeedFilter to a single hasFreshSpeakers() pass over the index, dropping
the now-redundant isLiveByPresence() check (hasFreshSpeakers implies it).
Migrate NestsFeedLoaded's latest-presence flow to the same index.
2026-04-30 14:18:37 +00:00
Claude 7c61eaf4e3 feat(nests): hide rooms with no fresh speakers from drawer feed
Adds hasFreshSpeakers gate to NestsFeedFilter so OPEN/PRIVATE rooms
whose live speaker slate is empty are dropped — a room with no fresh
kind-10312 presence carrying onstage=1 has effectively ended even if
its kind-30312 status still says live.
2026-04-30 14:18:37 +00:00
Vitor Pamplona 4f45de9b86 Merge pull request #2654 from vitorpamplona/claude/add-tag-filters-YR1wO
feat(home): user-configurable home tabs (new threads, conversations, everything)
2026-04-30 08:51:02 -04:00
Vitor Pamplona bc1a5d98d6 Merge pull request #2655 from vitorpamplona/claude/mute-unmute-icons-Lcxuc
feat(nests): use mic icons for talk / stop talking
2026-04-30 08:49:39 -04:00
Vitor Pamplona 89aca22897 Merge pull request #2657 from davotoula/sonar-unused-lambda-param
Replace unused lambda parameters with `_`
2026-04-30 08:47:38 -04:00
Vitor Pamplona 0ad45dadf6 Merge pull request #2658 from davotoula/fix/identity-claim-tag-parse
fix(nip39): reject identity claim tags without a platform separator
2026-04-30 08:47:24 -04:00
Vitor Pamplona ea5d020b05 Merge pull request #2659 from davotoula/fix/strictmode-cleartext-localhost
fix(android): silence StrictMode cleartext violations for 127.0.0.1 (Tor SOCKS)
2026-04-30 08:47:06 -04:00
David Kaspar 13b0332a13 Merge pull request #2650 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-30 14:40:11 +02:00
davotoula 6b709981de fix(android): silence StrictMode cleartext violations for Tor SOCKS on 127.0.0.1
Add a network_security_config.xml that keeps cleartext globally permitted
(so user-configured ws:// relays still work) but adds an explicit
domain-config for 127.0.0.1 and localhost. This stops StrictMode's
detectCleartextNetwork from flooding logcat with CleartextNetworkViolation
stacks each time the app talks to the local Tor SOCKS proxy
(220+ per benchmark session previously).

Verified on a playBenchmark build: zero CleartextNetworkViolation lines
to 127.0.0.1 even though the app continued attempting Tor connections
on port 9050.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:48:33 +02:00
davotoula 0c2614fbf5 fix(nip39): reject identity claim tags without a platform separator
Malformed `i` tags whose platform-identity field has no `:` (observed in
production logcat as e.g. `["i", " ", " "]`) made `create()` throw
`IndexOutOfBoundsException` from destructuring `split(':')`. The exception
was caught but each event spammed multiple stack traces. Validate the
separator up front in `parse()` and return null silently. Also fix the
diagnostic `joinToString { "," }` typo so the log shows real tag contents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:21:23 +02:00
davotoula 398652ef47 replace more unused lambda parameters with _ 2026-04-30 10:29:27 +02:00
Crowdin Bot 00386c242f New Crowdin translations by GitHub Action 2026-04-30 07:58:07 +00:00
davotoula 212fdf739c updated translations cz,se,de,pt 2026-04-30 09:54:46 +02:00
Claude 37bbb45090 fix(bottombar): preload feeds for user-pinned icons, not the default 5
LoggedInPage hardcoded the Home/Messages/Video/Discover relay subscriptions,
so users who customised the bottom bar still paid bandwidth for the four
defaults while the icons they actually pinned never preloaded. Drive the
preloaders from uiSettingsFlow.bottomBarItems so subscriptions track the
chosen list reactively.
2026-04-30 03:47:58 +00:00
Claude bfd3ddaca6 feat(home): user-configurable home tabs (new threads, conversations, everything)
Adds a Home Tabs settings screen letting users pick which tabs appear on Home:
New Threads, Conversations, and a new combined Everything tab. The tab row is
hidden whenever only one tab is active, so users can keep a single feed view.
At least one tab always remains active.
2026-04-30 03:29:41 +00:00
Claude 6da9af2a81 feat(nests): use mic icons for talk / stop talking
The text Talk / Stop Talking buttons made it easy to misread the
broadcast state. Swap them for large filled mic-icon buttons so the
mic state is unmistakable: MicOff in primary color when idle (tap to
go live), Mic in error color when broadcasting (mic is open, tap to
stop).
2026-04-30 03:28:53 +00:00
Vitor Pamplona 9bc1e3d3ce Merge pull request #2652 from vitorpamplona/claude/add-audience-to-stage-HhJgJ
Add tap gesture support for audience member promotion
2026-04-29 19:53:20 -04:00
Claude 208d9a8f7f feat(nests): tap an audience avatar to open the participant sheet
The host's primary path to add an audience member to the stage was the
long-press → "Promote to Speaker" row, which is hard to discover and was
also being swallowed by ClickableUserPicture (which ignores onLongClick
when onClick is null). Wire a tap handler on audience cells so a single
tap opens the same per-participant sheet, surfacing Promote to Speaker
for hosts and View Profile / Follow / Mute for everyone else.
2026-04-29 22:43:23 +00:00
Vitor Pamplona d44baa7e8f Merge pull request #2651 from vitorpamplona/claude/fix-windows-test-failure-dY1Jv
Fix race condition in MoQ subscription registration
2026-04-29 18:35:56 -04:00
Vitor Pamplona c8327e829f Merge pull request #2647 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-29 18:26:59 -04:00
Vitor Pamplona 31a2705173 Merge pull request #2649 from vitorpamplona/claude/fix-hand-raise-sync-Yf4b4
Fix stage presence sync and relay propagation in meeting rooms
2026-04-29 18:26:21 -04:00
Vitor Pamplona 997f52a31a Merge branch 'claude/fix-hand-raise-sync-Yf4b4' of https://github.com/vitorpamplona/amethyst into claude/fix-hand-raise-sync-Yf4b4 2026-04-29 18:19:22 -04:00
Claude 19b534a9e2 fix(nestsClient): register inbound moq-lite subscription before sending Ok
The publisher's inbound-bidi handler wrote SubscribeOk to the bidi
before calling registerInboundSubscription. The peer's first
publisher.send() after observing Ok could race the registration on
dispatchers that resume the peer's continuation before the handler's
(notably Windows under Dispatchers.Default), causing send to observe
an empty inboundSubs and return false. Reordering makes the peer's
view of Ok a happens-after of the registration.

Fixes the Windows-only failure in
MoqLiteSessionTest.publisher_acks_subscribe_and_pushes_group_data_on_uni_stream.
2026-04-29 22:14:23 +00:00
Claude 8d228db440 fix(nests): preserve relays tag on participant updates + sync action bar with stage grid
Two related bugs the user hit while testing against nostrnests:

1. Approving a hand-raise made the Hands tab disappear (the host's
   local app saw the role grant) but the audience member did not
   appear on stage anywhere else.

   Cause: RoomParticipantActions.rebuild() rebuilt the kind-30312
   without the original room's `relays` tag. The recently-added
   broadcast fan-out path (Account.computeRelayListToBroadcast) keys
   off `event.allRelayUrls()` for kind 30312, so a republished room
   event with no relays tag only reaches the host's outbox — not
   nostrnests's fixed five reads or the audience member subscribing
   on those reads. The audience member never sees their SPEAKER tag
   and stays absent from the participant list.

   Fix: copy `original.relays()` into the rebuild so every republish
   (approve, demote, kick → re-broadcast) keeps the relay routing
   that lets the room reach its full audience.

2. Tapping "Leave Stage" as the host emptied the StageGrid
   ("Waiting for speakers…") but the action bar still showed the
   Talk + Leave Stage cluster.

   Cause: two different definitions of "on stage" were in play.
   StageGrid uses ParticipantGrid (role + presence.onstage flag),
   so flipping onStageNow=false drops the host out. The action bar
   gated on the role-only `onStage: List<ParticipantTag>` (the
   host's HOST tag never goes away), so the cluster stayed.

   Fix: derive isOnStageMe from participantGrid.onStage so both
   surfaces share the same "stepped off" semantics. The host who
   taps Leave Stage now drops to the audience-style mute toggle
   and can rejoin (kind-10312 onstage flips back to 1) without
   the controls lying about their state.

https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
2026-04-29 22:13:36 +00:00
Vitor Pamplona 9628d17f5f Improves the relay list used for linked Notes to include their relay urls 2026-04-29 21:48:32 +00:00
Claude b0e7c62bb5 fix(nests): broadcast presence/chat to the linked room's full relay list
Hand-raise toggles in Amethyst weren't appearing in nostrnests's UI.

nostrnests's NestsUI v2 routes reads against a fixed five-relay list
(NestsUI-v2/src/lib/const.ts: relay.snort.social, nos.lol, relay.damus.io,
relay.ditto.pub, relay.primal.net) — no outbox model. Its presence query
is just `kinds:[10312], "#a":[roomATag]` over those five relays.

Commit 637174ef already taught computeRelayListToBroadcast to fan a kind
30312 *room* event out to its `relays` tag. But kind 10312 presence (and
chat / reactions, etc.) is a different event type that *links* to the
room via an `a` tag. For those, broadcast was only reaching:

  - the broadcaster's outbox relays
  - the single firstOrNull() hint baked into the `a` tag
  - the relay we happened to receive the room event from

If none of those overlap with nostrnests's fixed five reads, the
hand-raise update is silently dropped.

Extend the `linkedAddressIds` block in computeRelayListToBroadcast: when
the linked address resolves to a MeetingSpaceEvent / MeetingRoomEvent /
LiveActivitiesEvent, also add that linked event's allRelayUrls(). This
covers presence updates and any other room-scoped event that points at
a Nest via `#a`.

https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
2026-04-29 21:48:32 +00:00
Vitor Pamplona 9b583d99ff Improves the relay list used for linked Notes to include their relay urls 2026-04-29 17:44:11 -04:00
Claude ecc1573606 fix(nests): broadcast presence/chat to the linked room's full relay list
Hand-raise toggles in Amethyst weren't appearing in nostrnests's UI.

nostrnests's NestsUI v2 routes reads against a fixed five-relay list
(NestsUI-v2/src/lib/const.ts: relay.snort.social, nos.lol, relay.damus.io,
relay.ditto.pub, relay.primal.net) — no outbox model. Its presence query
is just `kinds:[10312], "#a":[roomATag]` over those five relays.

Commit 637174ef already taught computeRelayListToBroadcast to fan a kind
30312 *room* event out to its `relays` tag. But kind 10312 presence (and
chat / reactions, etc.) is a different event type that *links* to the
room via an `a` tag. For those, broadcast was only reaching:

  - the broadcaster's outbox relays
  - the single firstOrNull() hint baked into the `a` tag
  - the relay we happened to receive the room event from

If none of those overlap with nostrnests's fixed five reads, the
hand-raise update is silently dropped.

Extend the `linkedAddressIds` block in computeRelayListToBroadcast: when
the linked address resolves to a MeetingSpaceEvent / MeetingRoomEvent /
LiveActivitiesEvent, also add that linked event's allRelayUrls(). This
covers presence updates and any other room-scoped event that points at
a Nest via `#a`.

https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
2026-04-29 21:36:23 +00:00
Crowdin Bot 8dec96710f New Crowdin translations by GitHub Action 2026-04-29 21:31:01 +00:00
Vitor Pamplona f63e3b1c67 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-04-29 17:28:38 -04:00
Vitor Pamplona b77407f13d Merge pull request #2648 from vitorpamplona/claude/fix-nest-room-connection-H48ad
test(quic): add concurrent producer/consumer regression for SendBuffer
2026-04-29 17:19:17 -04:00
Vitor Pamplona df98235d31 Minor adjustments to remove warnings 2026-04-29 17:16:14 -04:00
Claude a84fbd2e57 test(quic): add concurrent producer/consumer regression for SendBuffer
The previous SendBuffer suite (FlowControlEnforcementTest) is entirely
single-threaded — every test calls enqueue and takeChunk sequentially
on the same coroutine, so the race that crashed the audio path in
production (NoSuchElementException from chunks.first() under
concurrent enqueue + takeChunk) stayed invisible. The whole :quic
commonTest tree had no concurrent test at all.

Three new tests run real-thread races on Dispatchers.Default:

  - concurrent_enqueue_and_takeChunk_does_not_throw drives multiple
    producer coroutines + a consumer coroutine and asserts the buffer
    drains cleanly with no exception.
  - concurrent_takeChunk_callers_never_double_drain_a_chunk fans out
    multiple consumers against a pre-populated buffer; asserts the
    sum of bytes handed out equals the bytes enqueued (i.e. no chunk
    is double-counted by overlapping head-peel paths).
  - concurrent_finish_with_inflight_enqueue_emits_correct_fin races
    finish() against in-flight writes and asserts the FIN comes
    AFTER every enqueued byte.

Tests pass against the synchronised SendBuffer; running them against
the pre-fix unsynchronised version corrupts state badly enough that
the consumer wedges (an explicit "this is what the bug looked like"
demonstration). With internal synchronisation in place the suite
finishes in <0.2 s.

Documents the concurrent-access contract so a future "let's drop the
sync, it's hot" refactor immediately fails CI.
2026-04-29 21:08:03 +00:00
Vitor Pamplona c88183809b Merge pull request #2646 from vitorpamplona/claude/fix-nest-room-connection-H48ad
Align nests servers with NIP-53 spec: separate relay and auth URLs
2026-04-29 17:03:27 -04:00
Claude 2d45c6ff4a fix(quic): make SendBuffer thread-safe to stop torn-state crash
QuicConnectionDriver.sendLoop holds the connection mutex while it calls
SendBuffer.takeChunk via QuicConnectionWriter.drainOutbound, but
WtPeerStreamDemux's per-stream `send` callback calls
SendBuffer.enqueue from arbitrary application coroutines without the
connection lock. The two paths concurrently mutate
(chunks, pendingBytes, headOffset, finPending, finSent, sentEnd,
nextOffset). Under load this surfaced as

  java.util.NoSuchElementException: ArrayDeque is empty.
    at kotlin.collections.ArrayDeque.first(ArrayDeque.kt:102)
    at com.vitorpamplona.quic.stream.SendBuffer.takeChunk(SendBuffer.kt:85)
    at com.vitorpamplona.quic.connection.QuicConnectionWriterKt.buildApplicationPacket(...)
    at com.vitorpamplona.quic.connection.QuicConnectionDriver.sendLoop(...)

The writer saw `pendingBytes > 0` (incremented by an in-flight
enqueue on another thread) before the matching `chunks.addLast`
became visible, fell into the head-peel branch, and tripped on
chunks.first().

Wrap every read and write of SendBuffer state in `synchronized(this)`,
including the cheap `readableBytes` / `sentOffset` / `finPending` /
`finSent` getters used by the writer's pre-flight checks (so they
can't read torn state either). The lock is uncontended in the common
case and short-held in the rare race; we already use synchronized
blocks elsewhere in commonMain (QuicConnectionDriver.kt).
2026-04-29 20:56:33 +00:00