49f769f92ab4421e527c46c7172bc5ad29fc5c8d
12178 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
49f769f92a |
Merge pull request #2590 from vitorpamplona/claude/fix-fdroid-lint-errors-i6aEY
fix(playback): hoist DataSourceBitmapLoader build into a function |
||
|
|
6540b81512 |
Merge pull request #2589 from vitorpamplona/claude/audit-remember-functions-6lpHA
perf(ui): drop remember wrappers where overhead exceeds savings |
||
|
|
f96b42f6b9 |
fix(lint): add @file:OptIn(UnstableApi::class) to MediaSessionPool
Property-level @OptIn doesn't propagate through the lazy{} delegate body,
so lint flags the DataSourceBitmapLoader.Builder chain (lines 87-90) with
UnsafeOptInUsageError. File-level annotation is a one-line fix that lets
:amethyst:lintPlayDebug pass without changing runtime semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
6f04edd995 |
perf(ui): drop remember wrappers where overhead exceeds savings
Cases removed: - Trivial Modifier allocations (Modifier.weight/padding/size) — slot table cost dominates the cost of building a fresh Modifier each recomposition. - Map.keys views over relayStatuses on desktop screens — .keys is a property read on the same map, no need to memoize. - coerceIn() arithmetic on AudioWaveform Dp/Float params — two compares are cheaper than the slot table read+compare. - fadeIn()/fadeOut() in AnimatedVisibility — small EnterTransition allocations that don't justify the slot table overhead. Audit-only changes; no behavior changes. https://claude.ai/code/session_011Ea2pVjwvCEx7X4izwryV4 |
||
|
|
179642d78b |
fix(playback): hoist DataSourceBitmapLoader build into a function
Android Lint's UnsafeOptInUsageError doesn't recognize @OptIn placed on a `by lazy` property as covering the lambda body, so each Media3 unstable-API call inside the initializer (Builder, setExecutorService, setDataSourceFactory, build) was flagged. Move the construction into a real function carrying the @OptIn annotation; the lazy delegate just calls it. No behavior change. |
||
|
|
d589fc4614 |
Merge pull request #2588 from vitorpamplona/claude/optimize-composables-performance-bpO05
perf(note types): cache event-derived values and hoist static modifiers |
||
|
|
46f305fe1a |
perf(chats): typed sealed key instead of concatenated string
The previous fix used `"ch:${id}"`, `"dm:${users.sorted().joinToString}"`
etc., which allocates a StringBuilder + char[] + new String per call —
worst case for the DM branch which also allocates a sorted List on top.
Replace with a sealed `ChatroomLazyKey` and per-type data classes that
just wrap the existing String / RoomId / ChatroomKey. Equality and
hashCode are auto-generated, so Compose still moves rows correctly on
reorder, and we drop most of the per-key allocations:
ch:abc -> PublicChannelLazyKey(abc) # 1 wrapper, reused String
dm:userA,userB -> PrivateChatLazyKey(chatroomKey) # 1 wrapper, reused ChatroomKey
eph:roomId -> EphemeralChannelLazyKey(roomId) # 1 wrapper, reused RoomId
|
||
|
|
06340dbdf9 |
fix(chats): stable per-chatroom LazyColumn key for messages list
The chatroom list keyed each row by `if (index == 0) index else item.idHex`. Two problems: 1. Position 0 was hardcoded to key `0`, so when a new chatroom moved to the top, the existing composition slot was reused with state from the previous chatroom — observed as "the row updated and reordered but still shows the old result" right at the top. 2. For other positions, the key was the latest message's `idHex`. When a new message arrived in any chatroom the chatroom's representative Note got replaced (different idHex), so Compose threw away the row and rebuilt it from scratch — wasted work. Fix: derive a stable key from chatroom identity instead of message id — nostr group id for marmot rooms, channel id for public/ephemeral channels, sorted user set for DMs. Falls back to `item.idHex` for unrecognized event types (drafts etc.). Reorders now move the row; new-message updates re-use the slot. |
||
|
|
6aecfe016b |
perf(note types): second pass — fix more missing remember keys
Follow-up to the first perf pass. Same goal: cut allocation cost for
note rows that scroll inside LazyColumn feeds.
- AppDefinition: key the `remember { tags.toImmutableListOfLists() }`
block by `note` so it actually invalidates when the note changes
- NIP90ContentDiscoveryResponse: drop the `remember(note) {
Modifier.fillMaxWidth() }` wrapper — `Modifier.fillMaxWidth()` is a
constant call
- PeopleList: key the `derivedStateOf` for `name` by `noteEvent`, and
switch `LaunchedEffect(Unit)` to `LaunchedEffect(noteEvent)` so the
participants reload when the underlying event changes
- PinList: replace `val pins by remember { mutableStateOf(noteEvent
.pinnedEvents()) }` with `val pins = remember(noteEvent) { … }` —
the `mutableStateOf` wrapper was unnecessary and the missing key
meant `pins` could go stale on event updates
- LongForm: return the `topics` list as `ImmutableList` so Compose
treats it as a stable parameter to the consuming `forEach`
- RelayList: drop the `mutableStateOf(RelayListCard(…))` wrap inside
4 `remember` blocks (DisplayRelaySet, DisplayNIP65RelayList write/
read, DisplayDMRelayList) — the value never changes after creation;
also key by `noteEvent` rather than `baseNote`, and cache
`noteEvent.description()`
- Torrent: wrap `noteEvent.title() + totalSizeBytes()`, content
comparison and `files().toImmutableList()` in `remember(noteEvent)`
so they don't recompute and reallocate on every recomposition
|
||
|
|
bf540db557 |
perf(note types): cache event-derived values and hoist static modifiers
Reduce per-recomposition allocation cost for note rows that render inside
LazyColumn feeds:
- AudioTrack: add `noteEvent` keys to `remember` for media/cover/subject/
participants/waveform/content so the cached values invalidate when the
underlying event changes
- Classifieds: wrap `imageMetas().map { MediaUrlImage(...) }`, title,
summary, price and location in `remember(noteEvent)` so they aren't
recomputed on every recomposition; hoist the static price-tag modifier
to a top-level `val`
- Report: collapse the per-recomposition `map { stringRes(...) }` chain
into a single `remember(reportTypes, noteEvent)` over a deduplicated
set of report types, and key the `base` collection by `noteEvent`
- Highlight: key the URL-parse `remember` by `url` so it actually
re-validates when the parameter changes
- PrivateMessage: key `remember { noteEvent.with(...) }` by `noteEvent`,
drop the silly `remember { Modifier.fillMaxWidth() }` wrapper, and
key `isLoggedUser` by `note.author` instead of `note.event?.id`
- PictureDisplay / FileHeader / Video: drop the unnecessary
`mutableStateOf(...)` wrap inside `remember` blocks that produce
immutable `BaseMediaContent` values; cache `images.map { it.url }`
preload list, and key `title`/`summary`/`image`/`isYouTube` by event
- Poll: add the missing `it.label` and `card` keys to `remember` blocks
that derive booleans from those parameters
- MeetingSpace: hoist the three `MeetingSpace*Flag` modifier chains to
top-level `val`s instead of allocating them each composition
|
||
|
|
864d14379a |
Merge pull request #2587 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations |
||
|
|
5c9bb64ab6 | New Crowdin translations by GitHub Action | ||
|
|
5be38ef3ac |
Merge pull request #2586 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations |
||
|
|
2bd655e31d |
Merge pull request #2584 from vitorpamplona/claude/optimize-video-loading-5opqr
Optimize video playback with warm player pool and buffer tuning |
||
|
|
a8e7c6c598 |
revert(video): drop the videoPlayerButtonItemsFlow remember
The remember(accountViewModel) { ... } I added was cargo-cult. The
getter is just a chain of val property accesses
(account.settings.syncedSettings.videoPlayer.buttonItems) returning the
same StateFlow instance every call. collectAsStateWithLifecycle keys on
that flow reference, which is identity-stable, so re-calling the getter
on every recompose costs nothing meaningful and doesn't cause a
re-subscription. Inline back to the original one-liner.
|
||
|
|
45fb5119e8 |
revert(video): two cleanups from the round-4 pass that didn't earn their keep
- GifVideoView: revert the dimensions remember() to the original one-line expression. Unlike VideoView's equivalent block, GifVideoView only *reads* — there's no MediaAspectRatioCache.add() side effect to gate. The replaced code spent three slot reads + three equality checks per recompose to skip an int division and an LruCache.get(), neither of which allocates. It was a wash at best, a small loss at worst. The original is simpler and roughly the same cost. - PlaybackServiceClient: bump the executor from newSingleThreadExecutor() back up to newFixedThreadPool(4). The work per listener is genuinely trivial in the steady state, but a single thread leaves us exposed to one stuck listener (e.g. the defensive 5s controllerFuture.get() timeout actually firing) stalling every other video on screen behind it. With a feed often holding several visible videos at once, that's a real regression risk. A fixed pool of 4 keeps us bounded against churn while letting independent listeners proceed in parallel. |
||
|
|
294ac3e74e | New Crowdin translations by GitHub Action | ||
|
|
d9b0f034f6 |
Merge pull request #2582 from vitorpamplona/claude/fix-translation-rendering-9fgk4
fix(translation): bug, perf and jitter overhaul of rich-text translation |
||
|
|
5ee09dfc04 |
Merge pull request #2583 from vitorpamplona/claude/fix-sqlite-parallel-inserts-Zx0Eu
Make event store operations async with coroutine support |
||
|
|
d7bd78cc32 |
chore(video): correctness and hygiene cleanups in playback layer
Round-up of the small leftovers from the audit. None move the needle on their own; together they remove a real cancellation bug and tighten the playback types. - PlaybackServiceClient.executorService: Executors.newCachedThreadPool() → Executors.newSingleThreadExecutor(). The work per callback is Future.get() on an already-completed future plus a non-blocking trySend; a single thread is plenty. The previous unbounded pool could spin up a thread per concurrent video, each lingering for the 60 s keep-alive afterwards. - MediaControllerState.controller: var → val. The field was never reassigned anywhere (grep confirms), and a non-observable var on a @Stable class is a footgun — Compose can't see writes to a plain var, so any future write would silently miss recomposition. - MediaControllerState.currrentMedia() → currentMedia(). Typo. Updated the single caller in PipVideoView. - LoadThumbAndThenVideoView: real cancellation bug fix. The Coil fetch was launched into AccountViewModel.viewModelScope via a side helper (loadThumb), so a scroll-away didn't cancel the in-flight image request — wasted bandwidth and a late callback writing into stale state. Inline the Coil call into the LaunchedEffect's own scope so cancellation propagates, and key the effect on thumbUri so a recycled audio-track slot with a new cover doesn't stall on the prior Pair(true, ...) gate. Drop the now-unused AccountViewModel.loadThumb and its only-here imports. |
||
|
|
63b20b88d0 |
refactor(translation): split UI from orchestration, dedupe boilerplate
Pure-readability refactor — no behaviour change, all 410 unit tests still pass.
TranslatableRichTextViewer.kt (358 → 192 lines)
- Extract the in-line LaunchedEffect block (cache check + ML Kit await + cancellation
bridge + result validation + caching) into a private `suspend translateAndCache`
function. The effect body is now four lines: try/catch around one call.
- Add a small `ResultOrError.toTranslationConfig(content)` extension that returns a
TranslationConfig only when an actual translation took place, replacing the
five-condition inline if/else inside the effect.
- Move TranslationMessage / LangSettingsDropdown / CheckmarkRow out to a sibling
file (TranslationStatusBar.kt). They render the "Translated from X to Y" footer
and don't belong in the orchestrator file.
TranslationStatusBar.kt (new)
- Renamed the public composable to `TranslationStatusBar` to make its role obvious.
- Split the status text and the dropdown into separate private composables so each
fits on screen at a glance.
- Add a tiny `LangMenuItem(checked, label, onClick)` to dedupe the four
`DropdownMenuItem { text = { CheckmarkRow(...) }, onClick = ... }` blocks.
- Hoist `rememberDeviceLocales()` out of the dropdown body for clarity.
- Cache `settings.preferenceBetween(source, target)` once per dropdown render
instead of calling it twice with identical args.
LanguageTranslatorService.kt
- Extract the in-flight cache plumbing into a `private inline fun dedupe(key, factory)`
helper. `autoTranslate` is now three lines that read top-to-bottom:
pre-filter, dedupe, identifyLanguage → translateOrSkip.
- Promote the inline `when` deciding whether to translate (matches translateTo,
is "und", is in dontTranslateFrom) into a named `translateOrSkip` function so
the policy is greppable.
TranslationDictionary.kt
- Add a `private inline fun Pattern.forEachMatch(text, block)` extension.
The four near-identical `val matcher = …; while (matcher.find()) addUnique(matcher.group())`
loops collapse to three one-liners; the URL detector loop stays explicit because
it has its own filter.
`inline` on dedupe and forEachMatch keeps the lambda allocations gone, so this is
a zero-cost refactor at runtime.
https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
|
||
|
|
3bf1448d63 |
docs(quartz/store): explain the connection pool and the suspend API
- Add a Concurrency section to the SQLite store README covering the Room-style 1-writer + N-reader pool, the in-memory degradation, and the non-reentrant Mutex contract. - Refresh the SQLite "How to Use" examples to call out the suspend context and recommend transaction-batching for hot inserts. - Switch the ExpirationWorker example from Worker to CoroutineWorker now that deleteExpiredEvents is suspend. - Note in the FS README that the IEventStore API is suspend even though the FS layer keeps a synchronous flock manager (the withWriteLock helper is inline so suspend bodies pass through). - Update the FsMaintenanceTest description to match the coroutine-based concurrency test. - Document the Mutex non-reentrancy footgun in SQLiteConnectionPool's KDoc so module logic doesn't try to re-enter the pool from inside useWriter. https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5 |
||
|
|
9fcf85bed0 |
fix(quartz/sqlite): serialise writes via a Room-style connection pool
androidx.sqlite SQLiteConnection is not thread-safe; SQLiteEventStore shared a single lazy connection across all callers, so two coroutines calling insertEvent() at the same time would race on BEGIN IMMEDIATE and the modules' prepared statements, surfacing as "cannot start a transaction within a transaction" or SQLITE_MISUSE. Mirror Room's design: introduce SQLiteConnectionPool with one writer connection guarded by a coroutine Mutex and N reader connections handed out via a Channel-as-semaphore (file-backed DBs only; in-memory DBs share the writer because each ":memory:" connection is a separate DB). Convert IEventStore + SQLiteEventStore + EventStore + FsEventStore + LiveEventStore to suspend, route writes through useWriter and reads through useReader. RelaySession now launches handleEvent / handleCount on its scope. CLI Context helpers and StoreCommands.sweepExpired pick up suspend. Add ParallelInsertTest to lock the behaviour in: 8 coroutines × 200 inserts, parallel reads alongside writes, transaction batches across coroutines, and a reopen smoke test all pass against a file-backed DB. https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5 |
||
|
|
ba1a1bfc12 |
perf(video): shorten VideoCache warmup delay from 10s to 1.5s
The existing background warmup in Amethyst.initiate() deferred the lazy
videoCache touch by 10 seconds. SimpleCache's constructor opens a SQLite
index via StandaloneDatabaseProvider and walks every cached span on disk
— a few hundred ms on a populated 4 GB cache — so we really do not want
that running on the main thread.
But 10 s is long enough that a fast user (or a deep link / push notification
that lands directly on a video-bearing screen) can win the `lazy { }` race
and trigger init on the main thread inside PlaybackService.onGetSession,
which is exactly the hitch the warmup was meant to prevent.
Drop to 1.5 s — long enough to let the urgent first-paint work above
(account load, image loader, ui state, robohash) breathe, short enough
that a typical user can't scroll and tap a video before the warmup wins.
Document the trade-off in a comment so the timing isn't a magic number.
|
||
|
|
c6b275e7fe |
perf(video): tighten remember keys and stabilize controller-overlay tree
Round-4 audit cleanups. Each item is small but each runs on the hot path
that recomposes during every active video, so they add up while scrolling.
P1 — DimensionTag identity invalidating remember:
- DimensionTag (in quartz) is a regular class with no equals override, so
reference equality means a freshly parsed tag for the same event is !=
to the previous one. The remember(videoUri, dimensions) blocks added in
the earlier perf commits were re-running their lambda on every recompose.
Switch to primitive (width, height) keys in VideoView and GifVideoView so
the cache lookups + MediaAspectRatioCache writes only fire when the
dimensions actually change.
P1 — Static gradient brushes:
- TopGradientOverlay / BottomGradientOverlay were calling
Brush.verticalGradient(colors = colors) inside the modifier chain, which
allocated a fresh Brush on every recomposition while the controllers
were visible (i.e. on every active video most of the time). Pre-build
both brushes as file-level vals so they're allocated exactly once per
process.
P2 — ImmutableList for action collections:
- RenderTopButtons / AnimatedOverflowMenuButton / OverflowMenuButton were
passing List<VideoPlayerAction> across composable boundaries. Plain List
is unstable in Compose, forcing the overflow tree to recompose any time
an unrelated parent state (volume, tracks, controllerVisible) ticked.
Use ImmutableList end-to-end via toImmutableList() at the producer side.
P2 — videoPlayerButtonItemsFlow remember:
- accountViewModel.videoPlayerButtonItemsFlow() was being called fresh
every recomposition, with the result handed straight to
collectAsStateWithLifecycle. Hoist the call into remember(accountViewModel)
so the flow reference is stable.
P2 — MuteButton dispatcher cleanup:
- The 2-second hold timer was using LaunchedEffect { launch(Dispatchers.IO)
{ delay(2000); holdOn.value = false } }. The wrapped launch was just
redundant dispatcher hopping — delay() doesn't hold a thread and the
Compose write is fine on Main. Inline it.
|
||
|
|
c9a19b90f0 |
perf(video): retain warm ExoPlayers and clear up the controller hot path
Closes the remaining audit items so the eager-prepare model also pays off on scroll-back. The big change is keeping the most recent N feed players paused-with-buffer instead of stop()'ing them on release. P0 — Warm-slot ExoPlayer pool: - ExoPlayerPool now retains up to N (default 3) paused players keyed by the mediaId they last loaded. Acquire takes an optional preferredMediaId hint and returns the matching warm player intact; only the cold fallback path runs stop()/clearMediaItems(). Warm slots count against the device's MediaCodec budget (poolSize) so the cold cap is poolSize - warmSize, with warm slots themselves capped at poolSize-1 to guarantee there's always at least one cold slot for a brand-new URI. - Plumb videoUri through connection hints (PlaybackServiceClient -> PlaybackService.onGetSession -> MediaSessionPool.getSession -> ExoPlayerPool.acquirePlayer) so the service can find a warm match. Constants for the bundle keys live on PlaybackService. - GetVideoController.onEach now checks state.controller.currentMediaItem?.mediaId before calling setMediaItem. On a warm hit it leaves the player and its buffer alone — calling setMediaItem in that case would reset the player and undo the whole point of the warm pool. STATE_IDLE survivors still get a re-prepare. P1 — Stop rebuilding the MediaController on transient lifecycle dips: - GetVideoController.collectAsStateWithLifecycle was tearing down and re-binding the MediaController every time the activity lifecycle dropped below STARTED (system dialogs, briefly switching apps, notification shade). Switch to plain collectAsState — the controller now lives until the composable actually leaves composition, so a brief lifecycle dip no longer costs a full IPC rebind + buffer reload. Real backgrounding still tears down via composable disposal. P2 — Smaller fixes flushed at the same time: - GetVideoController: only write controller.volume when it differs from target. Combined with the new dedup, several feed videos preloading no longer fire one volume IPC per ready callback. - CurrentPlayPositionCacher: the resume threshold was `5 * 60`, which in milliseconds is 300 ms — i.e. "always seek". Bump to 5_000 (5 s) so trivially short clips don't pay an extra seek + buffer flush at STATE_READY just to land 100 ms away from where they started. - MediaSessionPool: stop allocating a fresh DataSourceBitmapLoader per session — the loader has no per-session state, so it's now a single lazy instance shared across all sessions in a pool. - MediaSessionPool.cleanupUnused was racy: concurrent releases all won the time check and each launched a redundant sweep coroutine. Replace with a CAS-guarded AtomicLong on a nano-precision timestamp. |
||
|
|
91d194b11e |
test(translation): JVM unit tests for placeholder dictionary round-trip
Extracts the placeholder dictionary logic out of LanguageTranslatorService into a
pure-JVM TranslationDictionary helper so the round-trip can be unit-tested
without ML Kit / Android runtime, and adds 24 tests covering it.
The existing TranslationsTest is androidTestPlay-only — it needs a real device or
emulator with Google Play services, so it can't validate a refactor in plain CI
or local dev. The new tests cover the riskiest part of this branch: that PUA
placeholders survive an arbitrary "translation" of the surrounding text and
decode back to the exact original tokens.
Test coverage
- isWorthTranslating: short text / letterless text rejected, mixed letters+emoji accepted
- placeholder: produces single PUA codepoint, rejects out-of-range index
- build: collects URLs, NIP-19 nostr refs, Lightning invoices, NIP-08 #[N] refs
- build: deduplicates repeated occurrences and skips Chinese-punctuation URL false-positives
- encode/decode round-trip on plain URLs, multi-URL strings, and mixed real-world content
- encode replaces longer values first to avoid prefix collisions
- decode preserves user text containing the OLD "B0/C0/A0" tokens (regression for the
pre-rewrite collision bug)
- case-sensitive replacement preserves user text that differs only in case from a placeholder
- decode handles null and empty-dictionary inputs
- simulated translation (rewrite English to Portuguese around the placeholders) round-trips
#[0] and nostr:nevent1... unchanged
LanguageTranslatorService now delegates to TranslationDictionary.{build, encode,
decode, isWorthTranslating} — public API (autoTranslate / translate /
identifyLanguage / clear) and behaviour are unchanged.
Result: 410 tests run, 408 passed, 2 pre-existing skips, 0 failures.
https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5
|
||
|
|
de31d37c01 |
Merge pull request #2580 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations |
||
|
|
ed2419b6c7 | New Crowdin translations by GitHub Action | ||
|
|
30c2cca8a4 |
Merge pull request #2578 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations |
||
|
|
24b8fa12b4 |
fix(translation): bug, perf and jitter overhaul of rich-text translation
Fixes a cluster of issues in TranslatableRichTextViewer + LanguageTranslatorService that caused stale translations, redundant ML Kit work, and visible jitter on every note that scrolls into view. Bugs fixed - Effect now actually re-runs when "Translate to" / "Don't translate from" change. Previously LaunchedEffect(Unit) snapshotted the settings once and ignored subsequent updates. - Translation cache now keys on (content, translateTo, dontTranslateFrom) instead of just content, so changing the target language no longer serves a stale translation in the wrong language. - Cancelled / "no translation needed" outcomes are now cached, so language identification no longer re-runs on every recomposition / scroll-back of text in the user's own language or in the don't-translate set. - ML Kit Tasks are now awaited via kotlinx.coroutines.tasks.await with ensureActive() checks; cancelling the composable's coroutine no longer races against an in-flight callback that mutates Compose state after disposal. - Encoded placeholders no longer collide with arbitrary user text. Replaced the old "B0/C0/A0" tokens (which a user could legitimately type) with single Unicode Private Use Area codepoints, and made replacement case-sensitive so e.g. "b0" in body text is no longer rewritten on decode. - Translation pipeline propagates failures: continueWith now rethrows task.exception instead of silently calling .result on a failed sub-task. - buildDictionary protects legacy NIP-08 references (#[N]) via the placeholder table, replacing the fragile post-translation "# [" -> "#[" string fix. Performance - LanguageTranslatorService de-duplicates concurrent translation requests for the same (text, settings) via an in-flight ConcurrentHashMap, so reposts / notifications / threads sharing the same content fire one ML Kit pipeline instead of N. - executorService is now a private bounded fixed pool sized on availableProcessors() / 2 instead of a publicly-mutable unbounded cached pool that could spawn dozens of threads under heavy scroll. - Skip ML Kit entirely for texts shorter than 4 chars or with no letter codepoints (emoji-only, punctuation) — language identification is unreliable there anyway. - Translation cache bumped from 100 to 500 entries to cover long threads / long-form articles. - Single-call translation (one ML Kit call for the whole text) preserves sentence-level context across paragraphs that the old per-line split discarded. Jitter - Removed CrossfadeIfEnabled around the rich-text body. The old code rendered two full RichTextViewer trees (and re-parsed URLs / hashtags / NIP-19 references twice) during the ~300ms crossfade whenever a translation arrived. Body now swaps directly; only the translation toggle hint sits below. - Replaced derivedStateOf around a trivial ternary with a plain expression. - Locale.forLanguageTag(...).displayName memoized per source/target tag so the CLDR display-name lookup doesn't run on every recomposition of the "Translated from X to Y" hint. - Device-locale list lifted out of the dropdown render loop and remembered, so ConfigurationCompat.getLocales no longer fires per recomposition while the language menu is open. - Dropdown body is only composed when expanded — it was already cheap inside Material3's DropdownMenu, but skipping the wrapper composition entirely is measurably tighter. The exposed API (LanguageTranslatorService.autoTranslate / .translate / .identifyLanguage / .clear, ResultOrError) is unchanged; TranslatableRichTextViewer's two public composables keep their signatures, so the ~30 call sites and the existing TranslationsTest don't need any updates. TranslationConfig drops the showOriginal field — that toggle is now derived live from AccountLanguagePreferences.preferenceBetween(...) so changing the user's language preference is reflected immediately without invalidating the cache. https://claude.ai/code/session_0153e2sVbAijKxinQYa6cNx5 |
||
|
|
a11ba2e55d |
perf(video): warm up ExoPlayer pool, tune LoadControl, fix notification fall-through
Three small infrastructure fixes that support the eager-prepare model (every visible feed video calls setMediaItem + prepare immediately so it's ready when the user scrolls to it). - ExoPlayerPool.create(): the warmup that builds poolStartingSize players up front was already written but never invoked. Wire it from PlaybackService.lazyPool() the first time a pool is requested. The builds now run on the pool's main-looper scope with a yield() between each so they're spread across frames instead of stalling the UI in one ~150–600 ms burst. Idempotent via an AtomicBoolean. - ExoPlayerBuilder: install a feed-tuned DefaultLoadControl (10s/15s/750ms/2000ms) instead of the 50s/50s/2.5s/5s defaults. Every visible video preloads, so 5 simultaneous players were each trying to buffer 50s ahead — fighting for network and burning ~30 MB of buffer per HD player. Capping at 15s keeps the active video smooth, lets it start playing as soon as ~750 ms is buffered, and slashes peak memory on feeds with several preloads. - PlaybackService.onUpdateNotification: the third forEachIndexed loop was missing its return, so on the muted-but-playing fallback path super.onUpdateNotification was called once per playing session instead of once total. With multiple feed videos preloading simultaneously this was hammering the notification system every time a player changed state. Match the first two loops by returning after the first match. Also drop the unused `idx` from forEachIndexed. |
||
|
|
8e60a79eaf |
perf(video): reduce jitter and per-recomposition work in note video pipeline
Tightens the hot path that runs whenever a video appears inside a note in the feed (RichText -> ZoomableContentView -> VideoView). - VideoView: resolve aspect ratio once per (uri, dim), and prime MediaAspectRatioCache from the imeta dim tag so repeat appearances (PiP, dialog, list re-enter) don't have to wait for ExoPlayer's onVideoSizeChanged before reserving layout space. - VideoView: key the manual "tap to show" toggle on videoUri so a recycled feed slot doesn't inherit stale state from the previous video. - VideoViewInner: hoist proxyPortForVideo() into a remember(videoUri) — the result was being recomputed every recomposition only to be dropped by GetMediaItem's URI-keyed remember. - RenderVideoPlayer: stop holding container size in compose state. The size is only read in onDoubleTap, so a non-state IntArray holder removes a recomposition of the whole player tree on every layout pass. Also memoize isLiveStreaming() so the .m3u8 substring scan doesn't run on every recomposition. - RenderTopButtons: same isLiveStreaming() memoization. - GetVideoController: switch the remaining non-lambda Log.d call to the lambda overload so the message string isn't formatted when the log level is filtered out. |
||
|
|
bf93cb0553 | New Crowdin translations by GitHub Action | ||
|
|
c24e676004 |
Merge pull request #2577 from vitorpamplona/claude/quic-audio-rooms-v5ihC
feat(quic+nestsClient): pure-Kotlin QUIC v1 + HTTP/3 + WebTransport + MoQ listener stack |
||
|
|
4338e5e6c4 |
docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.
quic/plans/2026-04-26-quic-stack-status.md:
Post-mortem of the original docs/plans/2026-04-22 plan. Documents
what shipped vs what was estimated, the actual package layout (~8.5k
LoC, 39 test files, 5 audit rounds), the crypto delegation surface
(Quartz only — no BouncyCastle, no JNI), interop verification status
(aioquic + picoquic; nests not yet), and known deferred items
(STREAM retransmit, Initial-key discard, etc.).
nestsClient/plans/2026-04-26-audio-rooms-completion.md:
Punch list to ship audio rooms end-to-end:
M1 Listener wire-up in Amethyst UI
M2 Multi-speaker audience UX
M3 Foreground service for backgrounded playback
M4 Manual interop pass against nostrnests.com
M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
M6 Capture → encode → publish pipeline
M7 NestsSpeaker API
M8 App polish (reconnect, leave cleanup)
M9 Foreground service for speakers
~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.
Inline doc cleanup:
* Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
from active code; replaced with "today" or pointers to the
completion plan
* Removed "Kwik-based stub" references; QuicWebTransportFactory and
surrounding docs now describe :quic as the production path
* TlsClient header reflects non-null certificateValidator + the
JdkCertificateValidator / PermissiveCertificateValidator split
* SendBuffer header documents the best-effort no-retransmit mode
explicitly (was hidden behind a "Phase L will fix this" note)
* MoqMessage / MoqObject / MoqSession reflect listener-side as
shipped + publisher-side as Phase M5
CLAUDE.md:
* Module list now includes :quic and :nestsClient (was 5 modules,
now 7)
* Architecture diagram + sharing philosophy explain what each new
module owns
No production behaviour changes; doc + comment-only edits. Tests green.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
7f05fd6e2a |
fix(quic): round-5 audit fixes — concurrency, ack-eliciting flags, scope leaks
Two parallel audit agents inspected the round-4 commits for regressions and
concurrency hazards. Major findings:
ackEliciting regression (HIGH from core-regression report):
Round-4's ACK gating optimization (only emit ACKs when something
ack-eliciting was received) didn't update the parser's per-frame
handling. MaxDataFrame, MaxStreamDataFrame, MaxStreamsFrame,
NewConnectionIdFrame, HandshakeDoneFrame, ResetStreamFrame, StopSendingFrame,
NewTokenFrame all need ackEliciting=true per RFC 9000 §13.2.1. Pre-fix a
packet carrying only one of these would record the PN but never trigger
an ACK, causing the peer to PTO-retransmit forever.
HandshakeDoneFrame conditional (HIGH):
Pre-fix the dispatcher unconditionally set status=CONNECTED; if
applyPeerTransportParameters had just called markClosedExternally
(e.g. CID-validation failure), a later HANDSHAKE_DONE in the same
payload would resurrect the connection. Now only sets CONNECTED when
status is HANDSHAKING.
WT scope leak (CRITICAL from concurrency report):
QuicWebTransportSessionState.close() never cancelled the scope holding
the demux pump and capsule reader coroutines; both kept running past
close, retaining QuicStream / chunk channels indefinitely. Memory
growth on long sessions that opened/closed many WT sessions.
WtPeerStreamDemux.route() collector leak (CRITICAL):
The route function launches a coroutine to drain stream.incoming into
chunkChannel (UNLIMITED). Four early-return paths (truncated stream
type, mismatched WT signal, foreign session id) returned without
closing chunkChannel — collector kept running, channel grew unbounded.
Now wrapped in coroutineScope{} so the collector is joined on every
exit. Also explicitly cancels collector on the catch path.
RESET_STREAM stream-id ownership (HIGH):
Pre-fix the dispatcher closed the local read side on whatever stream
the peer named. RFC 9000 §3.5: the peer can only RESET_STREAM streams
where it owns a send side. A peer RESETting a CLIENT_UNI is
STREAM_STATE_ERROR (we own the only side). Now closes the connection
in that case.
looksLikeIpLiteral tightening (HIGH):
Pre-fix accepted "1.2.3.4.5", "1.2", "1." as IP literals — Java's
InetAddress.getByName resolves all of those via DNS, defeating
audit-4 #4's SNI-leak fix. Now strict: 4 dot-separated octets each
in 0..255, or contains a colon (IPv6).
Signal channels closed on teardown (MEDIUM):
closeAllSignals() helper closes peerStreamSignal +
incomingDatagramSignal alongside closedSignal; pre-fix only closedSignal
was closed and racing parser frames could still trySend into
never-consumed channels. Centralised the call so close() and
markClosedExternally both invoke it.
Driver.close() idempotency (HIGH):
A second concurrent close() (common: session close + read-loop death
racing) used to launch a parallel teardown that called scope.cancel()
while the first's joinAll was mid-flight. Now memoizes the launched
Job behind a synchronized block.
Driver.close() flush detection (MEDIUM):
Pre-fix spun on `pendingDatagrams.isEmpty()` to detect
CONNECTION_CLOSE flush, but the writer's CLOSING branch bypasses
pendingDatagrams entirely. Now spins on `connection.status ==
CLOSING`, which transitions to CLOSED only after drainOutbound builds
the close packet.
@Volatile on peerMaxStreams* (MEDIUM):
peerMaxStreamsBidi/Uni snapshots are documented lock-free; without
@Volatile, JLS allows long-tearing on 32-bit JVMs and the JIT may
cache stale values.
CertificateFactory parse inside try (MEDIUM):
Malformed cert chain bytes used to throw raw CertificateException
through the read loop. Now wrapped, so parse failure becomes a clean
CONNECTION_CLOSE.
GOAWAY id-regression observability (MEDIUM):
Pre-fix the QuicCodecException thrown on increasing GOAWAY id was
silently swallowed by route()'s catch. Now also surfaces via
peerGoawayProtocolError so the application/QUIC layer can act.
appendFlowControlUpdates uses streamsListLocked (perf):
The round-4 perf #10 fix introduced streamsListLocked (no
entries.toList per drain) but appendFlowControlUpdates still iterated
the Map. Now also uses the index-friendly view.
peerCloseDeferred completion on session close (MEDIUM):
awaitPeerClose() used to hang forever if the local side called close()
before any peer-initiated WT_CLOSE_SESSION arrived. Now cancelled with
CancellationException on local close.
Tests:
AckElicitingFramesTest pins the ackEliciting contract on every round-5
fix plus the ResetStream-on-CLIENT_UNI rejection.
JdkCertificateValidatorIpLiteralTest pins the tightened pattern via
reflection.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
920b36cdd6 |
perf(quic): round-4 perf-audit fixes — ACK gating, flow-control dirty-set, streams list view
Four perf wins from the round-4 audit, all in the steady-state hot path (audio rooms run at ~50 datagrams/sec/participant). Perf #1 — ACK frame gating (saves a frame + bandwidth on every drain): AckTracker.buildAckFrame now returns null when no new ack-eliciting packet has arrived since the last build. Pre-fix the writer emitted a redundant ACK frame on every outbound packet (~50/sec each direction) even when the only inbound traffic since last drain was ACK-only. RFC 9000 §13.2 only requires ACKs in response to ack-eliciting packets within max_ack_delay; gating on ackElicitingPending satisfies that without delay-timer machinery. Perf #11 — AckTracker.purgeBelow short-circuit: Common case: peer ACKs a high PN, we already pruned below it. Pre-fix triggered a full ListIterator walk anyway. Now bails out when the tail's start is already above the threshold. Perf #9 — Flow-control dirty-set: QuicStream gains a receiveDirtyForFlowControl flag set by the parser when readContiguous advances the frontier. The writer's appendFlowControlUpdates now skips per-direction-window lookup + threshold comparison for streams whose flag is unset. Big win for multi-stream sessions (audio rooms with N×M streams per participant). Perf #10 — Streams list view: QuicConnection maintains a parallel insertion-ordered List<QuicStream> alongside the streams Map. Writer's round-robin scan reads the list directly instead of allocating `entries.toList()` per drain. No removal path exists today; the insert points (openBidi/UniStream, getOrCreatePeerStreamLocked) update both. AckTrackerGatingTest pins the new gating contract: first build returns a frame; second build without new reception returns null; subsequent ack-eliciting reception re-arms; non-ack-eliciting receptions alone don't. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
21da61ad64 |
test(quic): comprehensive regression tests for round-4 fixes + coverage holes
Pins every behavioural change made in the round-4 audit-fix commit so a
future regression can't quietly resurrect any of the bugs.
FrameRoutingTest (new):
* RESET_STREAM / STOP_SENDING / NEW_TOKEN round-trip and don't kill
the connection on arrival
* Peer attempting CLIENT_BIDI / CLIENT_UNI stream IDs closes the
connection (STREAM_STATE_ERROR)
* MaxDataFrame raises sendConnectionFlowCredit; lower values ignored
* CONNECTION_CLOSE returns immediately — frames after it are not
dispatched (would otherwise create phantom streams on a closed
connection)
* HANDSHAKE_DONE at APPLICATION level is legal (ensures the level-
validation guard didn't over-fire)
* incomingDatagrams queue caps at MAX_INCOMING_DATAGRAM_QUEUE; oldest
entries dropped on overflow
ReceiveBufferFinTest (new): the audit-4 #4 silent-truncation fix
* isFullyRead() stays false when FIN arrives before a gap fills
* isFullyRead() flips true only after contiguous-end reaches finOffset
* Zero-length FIN frame at exact end marks stream complete
* finOffset is pinned at first observation (RFC 9000 §4.5)
QuicConnectionWriterTest (new): drainOutbound paths previously untested
* CLOSING-status drain produces a CONNECTION_CLOSE packet
* appendFlowControlUpdates raises stream.receiveLimit after consumer
drains > half window
* Writer enforces sendConnectionFlowCredit cap (audit-4 #9 — never
exceeds the peer's initial_max_data even with more bytes queued)
JcaAesGcmAeadTest (new, jvmTest): JVM-platform AEAD round-trip
* seal → open round-trip
* Different nonces produce different ciphertexts
* Rebuild path (same nonce twice) uses fallback cipher and still opens
* Corrupted ciphertext / wrong AAD → null
* key/nonce/tag length constants
ChaCha20Poly1305AeadTest (new): the seal side that prior tests skipped
* seal → open round-trip
* Bad tag / wrong AAD → null
* Wrong-size key/nonce throws IllegalArgumentException
WtPeerStreamDemuxTest: GOAWAY id-regression branch (audit-4 #5)
* Increasing GOAWAY id is rejected; previously recorded id stays put
CapsuleReaderTest: new strictness assertions
* WT_CLOSE_SESSION body < 4 bytes throws QuicCodecException
* Reason > 8192 bytes throws QuicCodecException
Notes:
* QuicConnectionDriver direct unit tests would require turning UdpSocket
from `expect class` into an interface; deferred — driver paths are
exercised end-to-end by InteropRunner and indirectly via every pipe-
based test.
* Decrypting client-emitted packets in tests requires server-side keys
(server's RX = client's TX with different cached cipher state in JCA);
writer tests assert side-effects on connection state instead.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
222a4e7d42 |
fix(quic): round-4 tier-1 + tier-2 audit fixes
Critical interop blockers + security/correctness gaps surfaced by the
parallel round-4 audit. All fixes have inline comments referencing the
audit finding number.
Frame layer:
* Decode RESET_STREAM (0x04), STOP_SENDING (0x05), NEW_TOKEN (0x07).
Pre-fix these fell through to the `unknown frame type` branch and
threw QuicCodecException through the read loop, killing the
connection. aioquic and picoquic emit RESET_STREAM regularly.
* Wrap decodeFrames in try/catch in dispatchFrames; on a decode
error, transition to CLOSED gracefully via markClosedExternally
instead of letting the exception escape the read loop.
Connection layer:
* Reject peer-attempted CLIENT_BIDI / CLIENT_UNI stream IDs that don't
map to a stream we opened (RFC 9000 §19.8 STREAM_STATE_ERROR).
* MaxDataFrame now actually updates sendConnectionFlowCredit (was a
no-op pre-fix; sustained sends silently stalled).
* Writer enforces sendConnectionFlowCredit and tracks
sendConnectionFlowConsumed so cumulative bytes stay under the
peer's initial_max_data cap.
* SERVER_BIDI peer-opened streams inherit sendCredit from
peer.initialMaxStreamDataBidiLocal (was 0L; reply path was wedged
until MAX_STREAM_DATA arrived).
* applyPeerTransportParameters validates initial_source_connection_id
and original_destination_connection_id (RFC 9000 §7.3 MUST checks);
mismatch closes with TRANSPORT_PARAMETER_ERROR.
* Cap incomingDatagrams queue at 256 (audio rooms ~50/sec; 5-second
burst). On overflow, drop oldest — fresh frames matter more for
live media. Pre-fix RFC 9221 datagrams were unbounded.
Stream layer:
* QuicStream.deliverIncoming now returns Boolean; parser closes the
connection with INTERNAL_ERROR on saturation rather than silently
dropping bytes (peer believes the bytes were delivered, application
sees a hole).
* ReceiveBuffer tracks finOffset and exposes isFullyRead(); parser
only closes the incoming channel after the contiguous read frontier
reaches the FIN offset (pre-fix closing on FIN-frame arrival
truncated streams that had gaps).
TLS hardening:
* certificateValidator is non-null. Tests pass an explicit
PermissiveCertificateValidator; null was a silent-MITM hazard.
* Drop SIG_RSA_PKCS1_SHA256 from accepted CertificateVerify
schemes (forbidden by RFC 8446 §4.2.3 in CertificateVerify).
* Hard-fail the PSK-Finished path: we never offer a pre_shared_key
extension, so a server skipping Certificate/CertificateVerify is
either misbehaving or a partial-MITM stripping cert proof.
* Validate ALPN: reject any ALPN the server selected that we didn't
offer (was previously accepted silently).
* Add APPLICATION-level inboundBuffer so post-handshake CRYPTO
(NewSessionTicket, KeyUpdate detection) reaches the
SENT_CLIENT_FINISHED handler.
* State.FAILED is now actually assigned on any handler throw;
pushHandshakeBytes refuses further bytes when in FAILED.
* IP-literal precheck before InetAddress.getByName so cert
validation doesn't trigger DNS A/AAAA lookups for hostnames
(audit-4 #4: leaked SNI/hostname over plaintext DNS).
WT layer:
* GOAWAY id-regression check (RFC 9114 §5.2: MUST NOT increase).
A server sending an increasing id raises QuicCodecException.
* WT_CLOSE_SESSION decoder rejects bodies < 4 bytes (mandatory
error-code field) and reasons > 8192 bytes.
* Capsule reader catches Throwable but separately rethrows
CancellationException; on parse error, completes peerCloseDeferred
exceptionally so awaitPeerClose() doesn't hang forever.
HTTP/3 + QPACK:
* Http3Settings.decodeBody rejects duplicate ids (RFC 9114 §7.2.4.1
H3_SETTINGS_ERROR).
* QpackInteger.decode bounds-checks shift before extending value;
defence-in-depth Long-overflow check on accumulated value.
* QpackDecoder static-table accesses go through a bounds-checking
helper that throws typed QuicCodecException; literal lengths are
range-checked before allocation.
Test infra:
* InMemoryQuicPipe accepts an injectable serverScid and constructs
its tlsServer with TPs that include the required CIDs.
* InProcessTlsServer emits stub Certificate + CertificateVerify
so the real (non-PSK) handshake path is exercised.
* Updated all test callers to use PermissiveCertificateValidator.
* Updated CapsuleReaderTest with negative-path assertions for the
new strictness.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
0023c73aeb |
test(quic): regression tests for receive-limit, incoming channel cap, coalesced-packet skip
Three pending audit-2/3 regression tests, plus the InMemoryQuicPipe helpers needed to drive them. ReceiveLimitEnforcementTest — peer overshooting per-stream receive limit must transition the connection to CLOSED via markClosedExternally. Mirror test verifies the boundary value (frameEnd == receiveLimit) does NOT close. QuicStreamIncomingChannelTest — the per-stream incoming channel is bounded at 64 chunks; trySend on saturation must not block (would deadlock the parser on the connection lock). Empty chunks are filtered. closeIncoming terminates the collector. CoalescedPacketSkipTest — RFC 9000 §12.2 / RFC 9001 §5.5: feedDatagram must walk across coalesced packets, must skip a packet that fails AEAD verification using peekHeader.totalLength (not break the loop), and must exit cleanly when a trailing header is truncated. Pipe additions: buildServerApplicationDatagram + coalesceDatagrams give tests the primitives to drive arbitrary server → client app-level frames. InMemoryQuicPipe also takes an optional tlsServer so tests can advertise non-default transport parameters. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
62a42cb36d |
fix(quic): audit-3 follow-ups + regression coverage
Cipher caching: cache JCA Cipher + SecretKeySpec per direction in a new
JcaAesGcmAead so steady-state seal/open avoids Cipher.getInstance("AES/GCM/
NoPadding") per packet (audit-1, audit-3 hot path). Initial-padding rebuild
edge case (re-encrypting the same PN with the same nonce) falls back to a
fresh cipher because JCA tracks (key, iv) pairs and rejects legitimate reuse.
Channel-based wakeups: replace the WT peer-stream poller's delay(5)
busy-loop with awaitIncomingPeerStream/awaitIncomingDatagram suspending
on conflated wakeup channels fired by the parser. Connection close also
closes a closedSignal so any awaiter unblocks promptly with null.
Driver close ordering: close() now joins the read + send loops with a
bounded timeout instead of yield()+cancel()-racing them. Catches the case
where scope.cancel() fired mid-socket.send, occasionally producing partial
datagrams or skipping CONNECTION_CLOSE entirely.
WT graceful close: spawn a CapsuleReader-driven coroutine on the CONNECT
bidi that decodes WT_CLOSE_SESSION and surfaces it via peerCloseSession +
awaitPeerClose. Previously the encoder existed but no decoder consumed
incoming capsules, so peer-initiated graceful close was silent.
GOAWAY: WtPeerStreamDemux decodes the GOAWAY varint body into
peerGoawayStreamId instead of `is Goaway -> Unit`-dropping it.
TLS transcript hash: incremental SHA-256 backed by JCA MessageDigest,
snapshotted via clone() — replaces the O(n²) "concatenate-everything-and-
re-hash on every snapshot" implementation. TLS 1.3 takes ≥3 snapshots per
handshake.
MAX_STREAMS routing: parser now bumps peerMaxStreamsBidi/Uni on inbound
MAX_STREAMS frames; openBidiStream/openUniStream throw QuicStreamLimitException
when the cap is reached instead of silently overrunning it. Initial cap
sourced from peer transport parameters.
Regression tests:
* CapsuleReaderTest – round-trip, split-chunk, partial, unknown types
* TlsTranscriptHashTest – snapshot determinism, no consume-on-snapshot
* PeerStreamLimitTest – TP-driven cap, MAX_STREAMS frame round-trip
* WtPeerStreamDemuxTest – CONTROL stream GOAWAY decode
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
c4cf92429a |
Merge pull request #2549 from vitorpamplona/claude/improve-desktop-design-iOokB
Add native OS theming and improve desktop UI layout |
||
|
|
2f9a0a0e03 |
fix(quic): live interop against aioquic + round-3 audit fixes
🎉 First successful live-interop handshake against a real reference impl. Drove our pure-Kotlin QUIC client at the aucslab/aioquic-http3-server Docker container; HANDSHAKE COMPLETE with negotiated ALPN=h3 and full transport parameter exchange: max_data=1048576, max_streams_bidi=128, max_streams_uni=128, idle_timeout=60000ms, max_datagram=65536 Every layer worked end-to-end over real UDP: packet codec, header protection, AES-128-GCM AEAD, TLS 1.3 with X25519, CertificateVerify, transport parameters, ALPN negotiation. The PermissiveCertificateValidator + InteropRunner main + Gradle :quic:interop task make this reproducible in one command. Round-3 audit fixes (8 critical bugs caught by 4 parallel reviewers): 1. feedDatagram coalesced-packet skip (RFC 9001 §5.5): `?: break` discarded all subsequent coalesced packets if any one of them failed to decrypt. Now uses peekHeader to advance over a failed packet, only breaking on a totally-unparseable header. 2. Receive-side flow-control enforcement: Parser was inserting STREAM frames without checking against the limit we advertised. A misbehaving peer could blow past initialMaxStreamDataX with no error; now triggers markClosedExternally with FLOW_CONTROL diagnostic. 3. Bounded incomingChannel (audit-2 finding finally addressed): QuicStream.incomingChannel was Channel.UNLIMITED — slow consumer + fast peer = unbounded heap growth. Now Channel(64), bounded by the per-stream receive limit. Combined with #2, memory growth is capped. 4. RetryPacket CID length validation: parse() didn't bounds-check dcidLen/scidLen against the RFC 9000 §17.2 1..20 range. Hostile Retry with cidLen=255 → readBytes throws QuicCodecException to the caller (instead of returning null for silent drop). Added explicit `!in 0..20 → return null`. 5. HelloRetryRequest detection: No HRR check — we treated it as a regular ServerHello, derived an ECDHE on garbage, then failed AEAD downstream with a confusing error. Now checks ServerHello.random against the SHA-256("HelloRetryRequest") magic value and throws cleanly. 6. CancellationException handling in WT factory: Catch-all wrapped CancellationException as HandshakeFailed, breaking structured concurrency. Now rethrows ce after closing the driver. 7. InteropRunner scope leak on timeout: parentScope was never cancelled — orphaned coroutines kept the IO dispatcher alive after main exited. Now scope.cancel() runs unconditionally; small delay gives the driver-launched teardown a moment to flush before exit. 8. RFC 9220 §3.1 SETTINGS-before-CONNECT documented as known limitation: The strict requirement to wait for SETTINGS_ENABLE_WEBTRANSPORT=1 before sending CONNECT requires more refactoring (the demux capturing peerSettings lives inside QuicWebTransportSessionState, which we don't build until after the request bidi opens). Tolerant servers (aioquic, quic-go) accept early CONNECT; documented for future strict-RFC fix. All :quic:jvmTest + :nestsClient:jvmTest pass. Live aioquic interop verified post-fix: handshake completes, transport parameters round-trip cleanly. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
d2a10c9e4a |
feat(quic): live interop harness — picoquic Docker + InteropRunner main
Setup for driving the pure-Kotlin QUIC client against real reference
servers, kicking off the live-interop validation that closes the loop on
the audit cycles.
PermissiveCertificateValidator (commonMain test-only):
Accepts any cert chain and any signature. For local dev servers (picoquic,
quic-go, nests-rs in Docker) where the system trust store would reject the
self-signed cert. Documented as test-only — must never be wired into
production code.
InteropRunner.main (jvmTest):
Standalone runnable that opens a UDP socket, builds a QuicConnection with
PermissiveCertificateValidator, drives the handshake under a configurable
timeout, and reports CONNECTED / HandshakeFailed / Timeout / UdpFailed
with peer transport parameters on success. Exit code 0 on connect, 1 on
any failure mode — wirable into CI.
`quic/scripts/run-picoquic.sh`:
One-line Docker harness that runs Christian Huitema's picoquic reference
server on UDP 4433. Picoquic is the IETF QUIC WG's reference impl and
the most permissive target for a fresh client (clear qlog traces, accepts
a wide range of transport params).
`./gradlew :quic:interop` Gradle task:
Wraps the runner so live-interop is one command:
./gradlew :quic:interop -PinteropHost=127.0.0.1 -PinteropPort=4433
Validated against a non-running server: returns Timeout cleanly with
exit 1 (the failure-path proves the harness wires correctly).
Workflow documented in `quic/scripts/README.md` covering picoquic,
quic-go, aioquic, quiche, and the eventual graduation path to the
quic-interop-runner Docker matrix at https://interop.seemann.io.
Next step: actually run picoquic in Docker and chase whatever real-world
incompatibilities surface. Each will be a focused fix commit.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
bd192fc649 |
fix(quic): PTO timer + IDN/IPv6 hostname + Huffman lookup table + amortized compaction
Closes the remaining items from the round-2 audit.
Driver send-loop PTO timer:
Loop suspends on `withTimeoutOrNull(ptoMillis) { sendWakeup.receive() }`
instead of unconditional receive. PTO doubles on each consecutive timeout
up to 60s; resets to 1s on any wakeup.
Without this, a single lost ClientHello with no inbound traffic to drive
wakeups left the connection wedged forever. The PTO wake now gives the
writer a chance to re-emit on retransmission.
JdkCertificateValidator hostname matching:
- IDN.toASCII normalization on both the cert SAN and the input host so
a SAN of `xn--bcher-kva.de` matches an input of `bücher.de`.
- IPv6 literals compared via InetAddress.getByName().hostAddress so
`::1` and `0:0:0:0:0:0:0:1` compare equal.
- Wildcard public-suffix-label rejection: requires the suffix to contain
at least 2 dots, so `*.com` is rejected even if a misbehaving CA
issued such a cert.
QpackHuffman.decode O(N×256) → O(N×L) where L is the number of distinct
code lengths (≤ 26):
Previously the inner loop scanned all 256 symbols per output byte. Now
it walks length buckets in ascending order and does a HashMap lookup at
each. For ASCII text the hot path matches at length 5-8.
Http3FrameReader.push + CapsuleReader.push amortized compaction:
Was O(N) memcpy on every push → O(N²) over the lifetime of a long-lived
stream. Now compacts only when consumed prefix is at least half the
buffer, giving amortized O(1) per byte.
All :quic:jvmTest + :nestsClient:jvmTest pass.
End of round-2 audit fixes. The branch now passes:
- Every RFC 9001 Appendix A test vector bit-for-bit
- The full in-memory client+server pipe handshake
- Hostile-input fuzzing of decodeFrames
- Coalesced-packets ACK regression
- Multi-cipher-suite (AES-GCM + ChaCha20-Poly1305) handshake
- Negative-path TLS rejection (session_id_echo, version, group, missing
extensions)
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
8f5280c9c3 |
fix(quic): WebTransport peer-stream demux + flow-control + perf cleanup
Round-2 audit found that without prefix-stripping on peer-initiated streams,
the server's HTTP/3 control stream would deliver its SETTINGS frame to the
application, breaking MoQ framing. Plus several flow-control + perf items
the audit flagged.
WtPeerStreamDemux:
Spawned per WT session in QuicWebTransportSessionState.init. Routes peer
streams by their leading varint(s):
- HTTP/3 CONTROL stream (0x00) → drains internally, captures peer
SETTINGS frame for inspection (peerSettings property).
- QPACK encoder/decoder streams (0x02/0x03) → drained to /dev/null
(we run with QPACK_MAX_TABLE_CAPACITY=0).
- WebTransport unidirectional (0x54) + matching quarter session id →
surfaces to app as a StrippedWtStream with the prefix stripped.
- WebTransport bidi signal (0x41) + matching quarter session id → same.
- Anything else → dropped per RFC 9114 §9.
pollIncomingPeerStream is now @Deprecated; the recommended path is the
incomingStrippedStreams flow on the session state. The nestsClient adapter
now consumes the stripped flow and emits StrippedWtReadStreamAdapter
through the existing WebTransportSession.incomingUniStreams API.
WT_CLOSE_SESSION decoder + CapsuleReader:
Added stateful capsule reader that yields parsed WtCloseSession
(errorCode + reason) from the CONNECT bidi. Wires into the future close
notification path; not yet bound to status flip but the parser is in
place.
Flow-control fixes from the audit:
- Per-direction receive window in getOrCreatePeerStreamLocked. SERVER_UNI
streams now use initialMaxStreamDataUni instead of bidi-remote.
- appendFlowControlUpdates picks per-direction window matching the
stream's StreamId.kindOf.
- MAX_DATA tracking via QuicConnection.advertisedMaxData high-water mark.
Previously the writer emitted a fresh MAX_DATA on every outbound
packet once the threshold was crossed (spam). Now: only when the new
value strictly exceeds advertisedMaxData.
Stream iteration round-robin:
Writer was iterating streamsLocked() in insertion order, starving streams
created later under MTU pressure. Added streamRoundRobinStart counter on
QuicConnection that advances after each drain.
SendBuffer alias defense:
takeChunk's fast path used to return the head ByteArray reference directly
when consuming the whole chunk — caller-owned byte arrays could be mutated
in-place. Now always copies, since downstream encoders pass the bytes to
AEAD.seal which assumes immutability.
UdpSocket cleanup:
- readBuf shrunk from 65 KiB to 2 KiB. QUIC datagrams cap at MTU; the old
65 KiB allocation was per-connection waste with no benefit.
- Removed pointless synchronized(readBuf) — only the read loop touches it.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
94a3d32a6d |
fix(quic): lifecycle hangs + TLS hardening from second-pass audit
Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.
QuicConnection / awaitHandshake hang fixes:
- QuicConnectionClosedException + markClosedExternally(reason).
- close() now also calls signalHandshakeFailed if !handshakeComplete.
- QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
(was: just flip status, leaving awaiters hanging forever).
- QuicConnectionDriver.readLoop has a finally-block that calls
markClosedExternally + wakeup so when the socket closes mid-handshake
or the server closes uncleanly, awaitHandshake() throws instead of
suspending forever.
QuicConnectionDriver.close() no longer cancels its own caller scope:
Previously close() was suspend, called connection.close (acquires lock)
→ wakeup → scope.cancel → socket.close. If close() is invoked from
inside the driver scope (e.g. by the WT factory's exception cleanup
path), scope.cancel cancels the very coroutine running close, so
socket.close may never run. close() is now non-suspend and dispatches
the teardown onto parentScope.launch so the cancel never reaches its
caller.
QuicWebTransportFactory.connect:
- readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
default 10s). Dead network → HandshakeFailed instead of forever-hang.
- Whole post-handshake setup (open control + request streams + read
response) now in try/catch that calls driver.close() on any unexpected
exception. Previously a thrown SocketException between awaitHandshake()
and the explicit non-2xx branch leaked the driver + UDP socket.
JdkCertificateValidator.validateChain authType:
Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
trust managers (RootTrustManager, NetworkSecurityConfig) gate
algorithm-specific pinning rules on this string and may reject mismatched
combos.
TlsExtension.decodeList bounds:
Inner reads were unbounded against the extension-list end. A malicious
server could claim totalLen=4 but encode an extension whose data length
declared 1000, reading past the supposed extension-list end into
trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
asserts r.position ≤ end after each inner decode.
TlsClient KeyUpdate close-on-receipt:
Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
rotates keys and we keep using the old ones, AEAD opens silently fail
→ connection wedges. Until we implement rotation, KeyUpdate must throw
so the QUIC layer closes cleanly with a fatal error instead of
desynchronizing.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
9d0a7ead7a |
fix(quic): packet codec hostile-input validation + RFC-correct Initial padding
Round-2 audit found I'd only fixed varint-truncation in Frame.kt, not in
LongHeaderPacket. Plus the datagram padding was wire-illegal AND broke
our own short-header receive path.
LongHeaderPacket.parseAndDecrypt + peekHeader:
- Both tokenLen and length now validated against r.remaining BEFORE
.toInt() truncation. Hostile peer sending length=2^62-1 → return null
instead of OOM/crash.
- dcidLen and scidLen validated 1..20 per RFC 9000 §17.2.
- peekHeader correctly handles Retry packets (no token-length, no
length, no packet-number fields per RFC 9000 §17.2.5) — returns
PeekedHeader with totalLength = bytes.size - offset instead of
reading nonexistent length field as garbage.
Buffer.ensure: doubling loop spins forever if buf.size == 0. coerce
starting newSize to ≥ 8 so the loop always terminates.
QuicConnectionWriter Initial padding (RFC 9000 §14.1):
Previously: built packets, computed datagram size, appended trailing
zero bytes after the last packet to reach 1200. That's wire-illegal
(RFC says PADDING frames inside the encryption envelope) AND breaks
our own short-header receiver because ShortHeaderPacket.parseAndDecrypt
uses bytes.size as packetEnd → AEAD authentication includes the trailing
zeros and fails.
Now: two-pass build. Phase 1 collects ACK+CRYPTO frames per level into
local lists (single takeChunk per level), builds natural-size packets.
If Initial is present and total < 1200, rewinds the Initial PN and
rebuilds with PADDING bytes appended to the encrypted payload. PADDING
is one 0x00 byte per frame so concatenating N zero bytes inside the
payload is N valid PADDING frames — the receiver collapses them to
nothing during decode.
PacketNumberSpaceState.rewindOutboundForRebuild() supports the rebuild.
InMemoryQuicPipe handshake test still passes — verifies the receiver
correctly accepts our new PADDING-frame Initials.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
02b03e143b |
test(quic): in-memory QUIC pipe (quiche-style) for full-stack handshake
Adds InMemoryQuicPipe — a quiche-Pipe-style harness that runs a real QuicConnection through the full handshake without touching the network. The "server" side wraps InProcessTlsServer in QUIC packet protection (Initial + Handshake long-header packets) and routes CRYPTO bytes between the layers. Direct port of the pattern from quiche/src/test_utils.rs (`Pipe`). InMemoryQuicPipeTest.client_connection_reaches_connected_via_in_memory_pipe verifies the full client receive path: - ClientHello at Initial level → server decrypts, drives TLS, replies - Server Initial packet (ServerHello) → client decrypts, derives handshake keys - Server Handshake packets (EE + Finished) → client verifies, derives 1-RTT keys - Client Finished at Handshake level → server verifies - Client status flips to CONNECTED, both directions of 1-RTT keys installed This is the test category three of the four mature QUIC implementations surveyed have or rely on: - quiche's `Pipe` is the gold standard (we ported it here) - quic-interop-runner is the network-level equivalent (Docker matrix) - kwik notably does NOT have one — uses Mockito + reflection instead It catches the largest class of bugs: wrong layer-to-layer wiring (e.g. TLS layer derives keys but QUIC layer doesn't install them, the hardcoded-cipher-suite C1 bug, AckTracker PN bug C2 across coalesced packets). Future tests can build on it: stream send/receive, datagram round-trip, flow-control stall, retransmission once we add it. Pipe currently supports AES-128-GCM only; ChaCha20 path validation is covered by TlsRoundTripTest at the TLS layer for now. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |