Commit Graph

12947 Commits

Author SHA1 Message Date
Vitor Pamplona 38b4eb5a97 Merge pull request #2757 from vitorpamplona/claude/local-test-relays-wjY3g
test(quartz): add :quartz-test-relay for in-process Nostr relay testing
2026-05-07 10:14:54 -04:00
Claude 2c0ad4fbf5 docs(geode): performance plans for future work
Four sketches, queued by impact, each grounded in current code paths
and observed benchmark numbers:

- event-ingestion-batching: SQLite group commit + EVENT pipelining +
  off-thread Schnorr verify. Targets 5–10× EPS on a fast SSD.
- live-broadcast-fanout-index: indexed filter matching to replace the
  O(N_subs × N_filters) per-event walk in LiveEventStore. Targets
  flat fanout p99 up to high subscriber counts.
- connection-scaling: shrink the per-session outQueue footprint
  (currently the dominant per-conn cost), tune Ktor CIO group sizes,
  reduce JSON parse allocations. Targets 10 000+ concurrent conns.
- negentropy-large-corpus: id-and-time-only snapshot path so NEG-OPEN
  on a 5M-event store doesn't materialise full Event objects, plus
  bounded-window defaults and concurrent-session caps.

Each plan names the verification benchmark to add. Plans are queued,
not committed work — README orders them by expected impact.
2026-05-07 14:05:11 +00:00
Claude c19bd4e92e refactor(geode): test fixtures + collectUntilEose helper, move to testFixtures sourceSet
The in-process relay was usable but every consumer test paid a 10–15
line tax for scope/client setup, manual cleanup inside the test body
(leaks on assertion failure), hardcoded "ws://127.0.0.1:7770/" magic
strings, and an inline collectUntilEose pattern reinvented per file.

Net: ~250 LOC removed, every test gets correct @After cleanup, and
the synthetic event builders no longer ship in geode's production jar.

- Move geode.fixtures (synthetic event builders) and the new
  geode.testing package from src/main to a new src/testFixtures
  source set via the java-test-fixtures plugin. Production geode jar
  no longer includes them; consumers wire them with
  testImplementation(testFixtures(project(":geode"))).
- Add geode.testing.RelayClientTest open class — owns hub, scope,
  client, defaultRelay, defaultRelayUrl with @After-driven cleanup.
- Add geode.testing.collectUntilEose / collectUntilEoseMulti
  NostrClient extensions with a shared subId counter and timeout knob.
  Replaces hand-rolled subscriber loops in 4+ files.
- Add RelayHub.DEFAULT_URL constant so tests stop typing
  "ws://127.0.0.1:7770/" inline.
- Migrate the 8 quartz NostrClient*Test files + geode's
  Nip01ComplianceTest to extend RelayClientTest and (where REQ/EOSE
  is the pattern) call collectUntilEose. Delete the redundant
  BaseNostrClientTest wrapper.
2026-05-07 13:42:20 +00:00
Claude c2f24a5213 refactor: rename quartz-relay module to geode
The relay implementation now stands as its own module. Fitting brand
for a Nostr relay shipped alongside the Quartz library — a geode is a
rock that holds quartz inside.

- Rename module directory quartz-relay/ -> geode/.
- Rename Gradle module path :quartz-relay -> :geode (settings.gradle).
- Rename Kotlin package com.vitorpamplona.quartz.relay -> com.vitorpamplona.geode.
- Rename application name + main class binding to match.
- Update the relay's NIP-11 advertised name to "geode" and the
  software URL to /tree/main/geode. Test asserting the doc updated.
- Refresh comments / Main.kt usage line / config.example.toml header.
- Update consumers: quartz/build.gradle.kts dependency path, and
  quartz NostrClient tests that import the in-process RelayHub.
2026-05-07 13:12:34 +00:00
Claude f7d4e33409 refactor: promote relay-server toolkit from quartz-relay to quartz
Generalize the operator-agnostic pieces so any embed of the relay
server can use them without depending on quartz-relay (Ktor, TOML,
operator wrapper). quartz-relay shrinks to its actual job: TOML
config, Ktor wiring, persistence sidecar, and the Relay composition.

- Nip98AuthVerifier -> quartz nip98HttpAuth (verify() now suspend,
  uses kotlinx Mutex for KMP). Pairs with HTTPAuthorizationEvent.
- PassThroughPolicy + KindAllowDenyPolicy + PubkeyAllowDenyPolicy +
  RejectFutureEventsPolicy -> quartz nip01Core/relay/server/policies
  alongside the existing EmptyPolicy / VerifyPolicy / FullAuthPolicy.
- Collapse EmptyPolicy into 'object EmptyPolicy : PassThroughPolicy()'
  (PassThroughPolicy is now an open class instead of abstract).
- BanStore -> quartz nip86RelayManagement/server. Reimplemented
  lock-free with kotlin.concurrent.atomics.AtomicReference + immutable
  state snapshots so it works in commonMain.
- DynamicBanPolicy -> renamed to BanListPolicy; lives next to the
  BanStore it consults. The "Dynamic" qualifier was a contrast to
  static policies in quartz-relay; in its new home the name describes
  what it actually does.
- Nip86Server -> quartz nip86RelayManagement/server next to
  Nip86Client. Drops the RelayInfo wrapper indirection: InfoHolder
  now operates on Nip11RelayInformation directly.
- InProcessWebSocket -> quartz nip01Core/relay/server/inprocess.
  Constructor takes NostrServer (was: the operator-side Relay
  wrapper) so any embed can wire the same in-process bridge.
- Move BanStoreTest, Nip86ServerTest, Nip98AuthVerifierTest into
  quartz/jvmAndroidTest. Adjust runBlocking-bodied tests so JUnit 4
  sees Unit returns. PoliciesTest stays in quartz-relay tests because
  it uses module-local SyntheticEvents fixtures.
2026-05-07 13:02:23 +00:00
Claude e78561af67 refactor(relay): split overgrown files + reduce duplication
Audit follow-ups, no behavior change.

- Centralize NIPs/name/version constants in RelayInfo so RelayConfig
  and the default doc share one source of truth.
- Extract NegSessionRegistry (NIP-77 state + open/msg/close) out of
  RelaySession; the connection class now routes commands.
- Move multi-filter snapshot union/dedupe onto LiveEventStore.
- Pull Nip86HttpRoute and WebSocketSessionPump out of LocalRelayServer
  (was 485 lines, three responsibilities).
- Collapse Nip86Server.dispatch repetition with withHex/withHexAndReason/
  withInt/withString helpers; reuse Hex.isHex64 instead of a local regex.
- Make Nip11RelayInformation (and nested types) data classes so
  Nip86Server uses the synthesized copy() directly — drops the
  hand-rolled field-by-field shim.
2026-05-07 12:31:27 +00:00
Claude c440186575 feat(relay): NIP-77 negentropy reconciliation
Server-side negentropy: NEG-OPEN snapshots the matching event set,
NEG-MSG drives the round-trip via the kmp-negentropy library, and
NEG-CLOSE frees per-subId state. Same access controls as REQ apply
to NEG-OPEN. supported_nips bumped to advertise "77".
2026-05-07 04:02:02 +00:00
Claude d49d4a1025 perf(relay): load benchmark + bump per-session outbound buffer
Adds an opt-in load benchmark suite (`-DrunLoadBenchmark=true`)
covering: held-open WebSocket count, single + concurrent EVENT
publish throughput, and live-event fan-out latency to N
subscribers on one connection.

Numbers from a small VM (4 GiB RAM, 4 vCPU, ulimit -n 4096):

  Connections (raw WS, one REQ each)
    100   :  100 OK, 0.7 s
    500   :  500 OK, 1.2 s
    1000  : 1000 OK, 2.0 s
    2000  : 1993 OK (hits the 4096-fd ceiling: 2 fds per WS)

  Single-publisher serial publish + OK
    10000 events, 13.2 s, 760 EPS (round-trip latency bound)

  Concurrent publishers (each on its own WS)
    parallel=2  :  807 EPS
    parallel=4  : 1452 EPS
    parallel=8  : 1989 EPS  ← knee
    parallel=16 : 1704 EPS  ← SQLite single-writer ceiling
    parallel=32 : 1778 EPS

  Fanout (one publish, N active subs on a single WS)
    100 subs  : 67 ms last
    500 subs  : 52 ms last
    1000 subs : 51 ms last
    2000 subs : 111 ms last

Bumped SESSION_OUTGOING_BUFFER from 1024 → 8192. The 1024 cap was
hit by the 2000-sub fanout test (2000 outbound frames into one
session's queue), causing the relay to drop the connection as a
slow-consumer protection. 8192 fits the realistic upper bound for
a high-fan-out client (a few thousand subs on one WS) and caps
per-session memory at ~2 MiB before we drop.

Numbers also confirm:
  - The SQLite single-writer plateau is around 2000 EPS on this
    box. Production behind WAL + a faster disk should beat that.
  - Each WebSocket consumes 2 file descriptors. Operators must
    raise `ulimit -n` to ~3× their target connection count.
  - Fan-out scales sub-linearly (2000 subs ≈ 2× the latency of
    100 subs) — the bottleneck is shared (broadcast + SQLite
    write), not per-sub.

Total :quartz-relay tests: 99 (4 new benchmarks, opt-in), 0 failures.
2026-05-07 03:41:49 +00:00
Claude b7ba3f6d15 fix(relay): security + concurrency audit + on-disk persistence
Addresses every Critical/High/Medium finding from the code-quality
audit, plus the operator request to persist NIP-86 admin state and
NIP-11 doc mutations across restarts.

## Security (audit Critical)

- C1 — NIP-98 verifier no longer trusts the client-supplied `Host`
  header. New `LocalRelayServer.publicUrl` (and `[admin].public_url`)
  must be set in any production deployment; the verifier compares
  against this fixed string. Falls back to Host for loopback unit
  tests with a docstring warning.

- C2 — NIP-98 replay protection. Verifier now keeps a bounded
  LinkedHashMap of recently-accepted event ids (TTL = 2× tolerance,
  max 1024 entries, LRU evicted) and rejects duplicates. Replay check
  runs LAST so a one-shot id isn't burned on a request that fails
  signature/url/method validation.

- C3 — NIP-86 admin POST body is now capped at 1 MiB (configurable
  via `LocalRelayServer.maxAdminBodyBytes`). The `Content-Length`
  header is honored as a fast pre-read reject; any body that exceeds
  the cap during streaming returns 413 PayloadTooLarge before being
  buffered.

## Concurrency / correctness (audit High)

- H1 — Per-session writer coroutine + bounded outbound queue
  (SESSION_OUTGOING_BUFFER = 1024). When the queue fills, the
  connection is closed cleanly instead of silently `trySend`-ing
  into a void; subscribers will never silently miss EVENT/EOSE
  again.

- H3 — `BanStore` kind ops serialize through a new `kindLock` so
  concurrent admin RPCs and policy reads see consistent state.
  `allowKind` and `disallowKind` are now symmetric: each removes the
  kind from the opposite set, so `allowKind(K)` after `disallowKind(K)`
  actually re-allows K.

- H4 — `InProcessWebSocket` reconnect after disconnect is now
  supported. Each `connect()` allocates a fresh CoroutineScope +
  drainer channel, so a prior `disconnect()` (which cancelled the
  previous scope) doesn't leave the new connect with a dead drainer.

- H5 — `Nip86Server.dispatch` re-throws `CancellationException`
  through the runCatching's getOrElse so structured concurrency
  works (cancellation no longer reported as an RPC error).

- M1 — `LiveEventStore.query` dedupes events between the historical
  replay and the live SharedFlow during the in-flight overlap window.
  Events seen during `store.query` are tracked in a transient set and
  filtered out of the live stream until EOSE; the set is dropped after
  EOSE so live-only events don't accumulate memory.

## Operator-facing (audit Medium / Low)

- L4 — Audit-trail log: every admin RPC writes a single structured
  line to stderr (pubkey + method + ok/error).

- L3 — RelayConfig.resolveInfo() default supported_nips list now
  matches RelayInfo.default() (both advertise NIP-86).

- M2 — Hex-id and pubkey params validated as 64-char hex on
  ban/allow/list methods; malformed input returns "invalid params"
  instead of being silently stored.

- M4 — argparse supports `--key=value` form in addition to
  `--key value`.

- M5 — `RelayHub.close()` is idempotent and rejects subsequent
  `getOrCreate` calls so a relay created mid-shutdown can't leak its
  store.

- M7 — `LocalRelayServer.stop()` sets a `shuttingDown` flag *before*
  notifying clients; new WebSocket upgrades during the grace window
  are rejected so they don't miss the NOTICE.

- L5 — Shutdown hook runCatching-wraps both `server.stop()` and
  `relay.close()` so a throw in one doesn't skip the other.

- `--verify` was renamed to `--no-verify` semantically (verify is
  the default). The `--verify` flag is no longer documented.

## On-disk persistence (operator request)

New `RelayStateStore` writes a single JSON sidecar file holding the
live NIP-11 doc + all NIP-86 ban / allow / kind lists. Atomic write
via temp + ATOMIC_MOVE rename so a crash mid-save can never leave the
file half-written.

- `BanStore` now takes an optional `onMutation: () -> Unit` callback;
  every mutation fires it. `Relay` wires it to `snapshot()` which
  captures BanStore + info into a `RelayPersistedState` and calls
  `RelayStateStore.save`.
- `Relay` accepts a new `stateFile: File?` constructor arg. At
  construction it loads the snapshot via `RelayStateStore.load` and
  seeds the in-memory state — using a private `seedFromSnapshot`
  bulk-load that bypasses the mutation callback so we don't write
  back what we just read.
- New config field `[admin].state_file = "/var/lib/quartz-relay/events.db.admin.json"`.
  Convention: place next to the SQLite event-store file.
- Corrupt state files are tolerated: log to stderr and start fresh
  (refuse to silently overwrite operator data).

## Tests

8 new persistence tests: cold-boot writes nothing, ban/allow/info
round-trip across restart, kind allow/deny round-trip, corrupt-file
recovery, atomic write (no leftover .tmp), in-memory mode when no
state file is configured, full RelayStateStore round-trip across all
sections.

Total :quartz-relay tests: 95, 0 failures.
2026-05-07 03:22:56 +00:00
Claude 7c7908d373 feat(relay): NIP-86 relay management API
Implements the operator-facing JSON-RPC admin protocol on the relay
side, layered on top of the existing NIP-98 HTTP-Auth + NIP-86 wire
types in :quartz. Same path as the NIP-01 WebSocket and NIP-11 GET —
HTTP POST with Content-Type application/nostr+json+rpc, gated by a
NIP-98 Authorization header signed by an operator-listed pubkey.

Architecture:
  - BanStore           — concurrent in-memory state for runtime
                         pubkey/event/kind ban + allow lists.
  - DynamicBanPolicy   — IRelayPolicy that consults BanStore on every
                         EVENT; auto-prepended to every Relay's policy
                         stack so admin actions take effect without a
                         restart.
  - Nip98AuthVerifier  — parses `Authorization: Nostr <base64-event>`,
                         verifies kind 27235 + Schnorr sig + ±60 s
                         clock skew + method/url/payload-hash match.
  - Nip86Server        — JSON-RPC dispatcher. Mutates BanStore for
                         ban/allow/list methods, mutates the live
                         RelayInfo doc for changerelayname/desc/icon
                         (Relay.info is now @Volatile var, mutation
                         through Relay.updateInfo).
  - LocalRelayServer   — adds POST handler at the relay path:
                         403 if no admin pubkeys configured,
                         401 if NIP-98 missing/invalid,
                         403 if signer's pubkey isn't on the admin list,
                         200 with Nip86Response otherwise.
  - RelayConfig.AdminSection — `[admin].pubkeys = [...]` config key.
  - RelayInfo.default() now advertises NIP-86 alongside 1/9/11/40/42/45/50/62.

Methods implemented:
  supportedmethods, banpubkey, unbanpubkey, listbannedpubkeys,
  allowpubkey, unallowpubkey, listallowedpubkeys,
  banevent (also deletes from store), allowevent, listbannedevents,
  allowkind, disallowkind, listallowedkinds,
  changerelayname, changerelaydescription, changerelayicon.

Tests (28 new):
  - BanStoreTest (6)            — ban/allow round-trips, case-insensitive
                                  pubkey match, kind allow/deny precedence,
                                  audit-trail listing.
  - Nip86ServerTest (8)         — every method's accept/reject path.
  - Nip98AuthVerifierTest (8)   — happy path, missing/wrong scheme,
                                  url/method/payload-hash mismatch,
                                  stale created_at, wrong event kind.
  - Nip86EndToEndTest (6)       — real HTTP POST through LocalRelayServer:
                                  supportedmethods returns 200,
                                  outsider returns 403, no auth header
                                  returns 401 + WWW-Authenticate, banpubkey
                                  blocks subsequent EVENT publish over WS,
                                  changerelayname flows to NIP-11 GET,
                                  admin endpoint is disabled when no
                                  pubkeys configured.

Total :quartz-relay tests: 87, 0 failures.
2026-05-07 02:55:47 +00:00
Claude 9ebdfc0b81 feat(relay): drop max_event_bytes + rate-limit configs; verify on by default
Removes config keys + policies that don't earn their complexity:
  - [limits].max_event_bytes / MaxEventBytesPolicy — duplicates
    [limits].max_ws_frame_bytes which is the right layer (Ktor frame
    cap at the wire), and the policy-level check ran AFTER the event
    was already parsed and bound for the store.
  - [limits].messages_per_sec, [limits].subscriptions_per_min /
    RateLimitPolicy — per-session token buckets without the matching
    per-IP / global-EPS infrastructure are mostly cosmetic; an
    operator who needs real rate limits will use a reverse proxy.

Also flips [options].verify_signatures default from `false` to `true`.
A relay accepting traffic from real clients should verify Schnorr
signatures, and verify-by-default closes the footgun of forgetting
the flag. The CLI gains `--no-verify` for explicit opt-out
(test fixtures, mirror replays); the old `--verify` is dropped (a
no-op now anyway).

Removed:
  - quartz-relay/.../policies/MaxEventBytesPolicy.kt
  - quartz-relay/.../policies/RateLimitPolicy.kt
  - 7 obsolete tests in PoliciesTest + 1 in PoliciesIntegrationTest
  - The matching config fields in LimitsSection
  - Sample config entries in config.example.toml
  - Wiring in Main.composePolicy

Added:
  - Test verifySignaturesCanBeExplicitlyDisabled covering the
    new explicit-opt-out path.

Total :quartz-relay tests: 59, 0 failures.
2026-05-07 02:36:18 +00:00
Claude 65311590f2 feat(relay): graceful drain on LocalRelayServer.stop
Closes the "100ms grace can drop in-flight EVENTs" gap from the
audit.

Behavioural change:
  - stop() default grace is now 5 s (was 100 ms) and total timeout 10 s
    (was 1 s). A SQLite write + OK reply round-trip easily fits.
  - stop() first sends a NOTICE("closing: relay is shutting down —
    please reconnect later") to every connected client, so well-
    behaved clients can reconnect rather than hammering a dead socket.
  - Active client sessions are tracked in a ConcurrentHashMap-backed
    set populated by the WebSocket handler's connect/finally pair.
    Exposed read-only via [activeSessionCount] so operators (and
    tests) can observe lifecycle.
  - stop() is now idempotent.

Tests (4 new in GracefulShutdownTest):
  - activeSessionCountTracksConnectAndDisconnect — counter goes
    0 → 1 on subscribe, → 0 on disconnect.
  - stopSendsShutdownNoticeToActiveClients — connected client
    receives a NOTICE whose message starts with "closing:".
  - stopIsIdempotent — second stop() is a safe no-op.
  - rawWsClientObservesNoticeBeforeServerCloses — bare OkHttp ws
    client sees the NOTICE frame before the server closes the socket
    (proves the order: NOTICE first, then engine.stop).

Total :quartz-relay tests: 66, 0 failures.
2026-05-07 02:22:39 +00:00
Claude 633d0c3c74 feat(relay): enforce [limits] + [authorization] config sections
Wires the parsed-but-unenforced config sections into actual relay
behavior via five new IRelayPolicy implementations under
quartz-relay/.../policies/. They compose through the existing
PolicyStack so operators stack only what they need; cheap rejection
paths run before expensive ones (rate-limit → AUTH → future/size/lists
→ signature verification).

New policies:
  - KindAllowDenyPolicy           — kind_whitelist + kind_blacklist
  - PubkeyAllowDenyPolicy         — pubkey_whitelist + pubkey_blacklist
                                    (case-insensitive)
  - RejectFutureEventsPolicy      — options.reject_future_seconds
  - MaxEventBytesPolicy           — limits.max_event_bytes
                                    (size of canonical NIP-01 JSON form)
  - RateLimitPolicy               — token-bucket per session for
                                    messages_per_sec + subscriptions_per_min;
                                    monotonic clock so wall-time jumps
                                    don't reset buckets.

Plus:
  - LocalRelayServer now honors limits.max_ws_frame_bytes /
    limits.max_ws_message_bytes via Ktor's WebSockets.maxFrameSize.
  - Main.kt builds the policy stack from RelayConfig in composePolicy().
    Warning surface is reduced to only the three sections still
    pending (max_subscriptions_per_session, max_filters_per_req,
    network.remote_ip_header).
  - PassThroughPolicy base lets each policy declare only the hook(s)
    it actually enforces, keeping call sites readable.

Tests (20 new):
  - PoliciesTest (16) — per-policy unit coverage for each accept/reject
    boundary, including allow + deny precedence, case-insensitive pubkey
    matching, deterministic rate-limit refill via injected clock, and
    composition via IRelayPolicy.plus.
  - PoliciesIntegrationTest (4) — end-to-end through NostrClient →
    RelayHub → Relay; proves OK false comes back over the wire when
    policies reject (kind blacklist, pubkey allow-list, future
    timestamps, oversize events).

Total :quartz-relay tests: 62, 0 failures.

Also: bump Nip40 deleteExpiredEvents wait from 1500ms to 2500ms — the
previous margin was thin enough to flake on busy CI when the SQLite
unixepoch() rounds down across the wait.
2026-05-07 02:14:42 +00:00
Claude cbe5dbe07b test(relay): comprehensive NIP coverage — 09, 40, 42 (success), 62
Closes the gaps from the test audit. Adds 24 new tests covering
features we advertise in NIP-11 but previously had zero coverage for,
plus regression tests for the wire-format bugs fixed earlier on this
branch.

New files:
  - Nip09DeletionTest (4 tests)  — deletion removes targeted events,
    deletion event itself is queryable, reinsert is blocked, cross-
    author deletion is ignored.
  - Nip40ExpirationTest (3 tests) — expired-on-arrival events
    rejected, future-expiration events stored and retrievable,
    deleteExpiredEvents() purges past-expiration entries.
  - Nip62VanishTest (3 tests) — vanish cascades prior events,
    blocks re-insertion of older events, doesn't affect other authors.

Extended LocalRelayServerTest (+4 tests, now 9):
  - nip42_successfulAuthUnlocksPublishing  — full AUTH dance over
    real WebSocket: relay challenge → RelayAuthenticator signs →
    OK true → publishAndConfirm now succeeds.
  - nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage —
    regression test for the OkMessage serializer bug fixed earlier.
  - nip11_servesConfigDrivenInfoDoc — custom RelayInfo flows
    through to the NIP-11 GET response.
  - nip01_closeStopsLiveSubscription — CLOSE over the wire
    actually terminates a live subscription.

Extended Nip01ComplianceTest (+5 tests, now 18):
  - reqFiltersByPTag, reqFiltersByGenericSingleLetterTag (#t),
    reqTagFilterValuesAreOred, reqMultipleFiltersAreOred,
    multipleSubscriptionsOnOneConnectionAreIndependent.

Total: 42 tests in :quartz-relay (was 23), 0 failures.
2026-05-07 01:38:42 +00:00
Claude e214143def feat(relay): TOML config file (--config /path/to/relay.toml)
Adds operator-facing TOML configuration to :quartz-relay, with the
section layout deliberately mirroring nostr-rs-relay's config.toml so
existing operators can port across with little churn.

Sections parsed AND enforced today:
  [info]      — NIP-11 doc fields (name, description, contact, pubkey,
                 software, supported_nips, …); replaces the previous
                 hardcoded RelayInfo.default()
  [network]   — host, port, path
  [database]  — in_memory toggle + file path
  [options]   — verify_signatures, require_auth (compose to the right
                 IRelayPolicy stack)

Sections parsed today but NOT YET ENFORCED (forward-compat for the
upcoming rate-limit / authorization work — relay logs a warning when
they're set):
  [limits]         — max_event_bytes, messages_per_sec, …
  [authorization]  — pubkey_whitelist/blacklist, kind_whitelist/blacklist
  [options].reject_future_seconds
  [network].remote_ip_header

CLI flag precedence over the config file is preserved: --host, --port,
--path, --info, --db, --auth, --verify all override the matching field.

Adds:
  - cc.ekblad:4koma 1.2.0 for TOML parsing
  - quartz-relay/config.example.toml as the canonical operator reference
  - 5 unit tests (defaults, full parse, NIP-11 mapping, bundled example
    file, optional sections)
2026-05-07 01:21:58 +00:00
Claude 58f6a0af6b fix(relay): forward ephemeral events to live subscribers (NIP-01)
LiveEventStore.query had a race window between emitting EOSE and
registering as a SharedFlow collector — any event emitted in that
window was lost because newEventStream has replay=0. The race was
usually masked by SQLite write latency for persisted kinds, but it
fired reliably for ephemeral kinds (20000-29999) where store.insert
is a no-op.

Per NIP-01 ephemeral events are not persisted but MUST still reach
matching active subscriptions. Fixed by registering the live
collector before signalling EOSE via Flow.onSubscription.

Adds two tests: one proves an ephemeral event reaches an active
subscriber, the other proves it isn't persisted (a follow-up REQ
returns zero events).
2026-05-07 01:03:06 +00:00
Claude eb80dbcd15 feat(quartz-relay): rename + promote to a real Nostr relay
Renames :quartz-test-relay to :quartz-relay and promotes it from a
test-only fixture to a real, runnable Nostr relay that just happens
to also be used in tests.

Two transports now share the same `Relay` core:

- `RelayHub` + `InProcessWebSocket` — no socket, fastest path; ideal
  for unit tests inside one JVM.
- `LocalRelayServer` — Ktor `embeddedServer` (CIO engine) listening
  on a real `ws://` port. Use for `cli` interop tests, Android
  instrumented tests, or running the relay standalone.

NIPs implemented (all driven through production `NostrClient` in the
new `LocalRelayServerTest`):

- NIP-01 wire protocol (REQ/EVENT/EOSE/CLOSE) over real WebSockets
- NIP-09 deletion (via existing `DeletionRequestModule`)
- NIP-11 relay info doc — `RelayInfo` wraps the existing
  `Nip11RelayInformation` model and is served on HTTP GET when
  `Accept: application/nostr+json` is requested. Loadable from a
  JSON config file via `RelayInfo.fromFile(...)`.
- NIP-40 expiration (existing `ExpirationModule`)
- NIP-42 AUTH (existing `FullAuthPolicy`, opted-in via the
  `--auth` flag in the standalone runner)
- NIP-45 COUNT
- NIP-50 search via the existing FTS index
- NIP-62 right-to-vanish (existing module)

Adds a standalone runner: `./gradlew :quartz-relay:run --args="--port 7447 --verify"`
binds the relay to a real port. Flags: --host, --port, --path,
--info <file>, --db <file>, --auth, --verify.

Test-only event generators moved to a clearly-named
`com.vitorpamplona.quartz.relay.fixtures` package so they're still
shareable with consumer tests without polluting the production API.

Adds `LocalRelayServerTest` covering NIP-01/11/42/45/50 over a real
loopback WebSocket. Existing 11-test `Nip01ComplianceTest` (in-process
transport) continues to pass.
2026-05-07 00:56:57 +00:00
Claude aefecf71d8 test(quartz): add :quartz-test-relay for in-process Nostr relay testing
Replaces production-relay (wss://nos.lol, wss://nostr.bitcoiner.social)
dependencies in 10 quartz JVM relay tests with a deterministic
in-process Nostr relay built on top of the existing NostrServer +
EventStore, plus a NIP-01 compliance suite that exercises the bridge
end-to-end through NostrClient.

The new :quartz-test-relay module exposes:
- TestRelay / TestRelayHub: NostrServer + in-memory EventStore per URL,
  registry implements WebsocketBuilder so tests can drop it into
  NostrClient in place of BasicOkHttpWebSocket.Builder.
- InProcessWebSocket: bridges the WebSocket abstraction to RelaySession
  with a single-coroutine drain to preserve message ordering.
- SyntheticEvents / RelayFixtures: deterministic event generators and a
  loader for the existing nostr_vitor_*.json corpora.

Found and fixed three NIP-01/NIP-45 wire-format bugs in the relay
serializers that prevented round-trip through the in-tree NostrClient:
- OkMessage wrote success as a JSON string instead of a boolean,
  causing publishAndConfirm to hang.
- CountMessage dropped the queryId, breaking COUNT response routing.
- CountResult used the field name "pubkey" instead of "approximate".

Both Jackson and kotlinx-serialization paths were affected; both fixed
to match NIP-01/NIP-45 wire formats.
2026-05-07 00:41:02 +00:00
Vitor Pamplona 4538812d26 Merge pull request #2756 from vitorpamplona/claude/optimize-ci-fetch-speed-giRgg
Downgrade VLC version to 3.0.20 due to Maven artifact availability
2026-05-06 19:09:32 -04:00
Vitor Pamplona e8c99943db Merge pull request #2755 from vitorpamplona/claude/implement-i4-stereo-interop-MtVnl
Add stereo audio support to nests speaker broadcasts
2026-05-06 19:06:59 -04:00
Claude d2294247a7 fix(desktop): pin vlcVersion to 3.0.20 so Linux vlcDownload finds an artifact
The Linux build path of the vlc-setup plugin pulls vlc-plugins-linux from
Maven Central (ir.mahozad:vlc-plugins-linux), which only ships 3.0.20 and
3.0.20-2 — there is no 3.0.21 artifact yet. With vlcVersion set to 3.0.21,
both the CI pre-fetch step and the in-Gradle vlcDownload task hit a 404.

Match VLC_VERSION in build.yml and document the lag so future bumps wait
for the Maven artifact to catch up.

https://claude.ai/code/session_01GrZLMi3sdp6frwREmQ9cUi
2026-05-06 23:06:52 +00:00
Claude 23b8bfd34a refactor(nests): per-stream channel count + AudioBroadcastConfig (I4 prep)
Split the previously global `AudioFormat.CHANNELS = 1` into a
`DEFAULT_CHANNELS` constant + per-call-site `channelCount` parameters
so a single broadcast can advertise stereo Opus without forcing every
mono call site to grow a new argument. Generalises the catalog factory
to `MoqLiteHangCatalog.opus48k(name, channels)` with memoised JSON
bytes per shape, threads a new `AudioBroadcastConfig(channelCount)`
through `connectNestsSpeaker` / `connectReconnectingNestsSpeaker` /
`MoqLiteNestsSpeaker`, and adds a `channelCount` parameter to
`MediaCodecOpusEncoder`. Production behaviour is unchanged for
mono callers (the new config defaults to mono); the listener side
already discovers the channel count from the catalog via
`NestViewModel.awaitAudioPipelineConfig`. No test or wire changes.

Phase 1 of `nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md`.
The hang-interop test scaffolding (`HangInteropTest`, `runSpeakerToHangListen`,
Rust `hang-listen` / `hang-publish`, `JvmOpusEncoder`) doesn't exist on
this branch yet, so the I4 forward + reverse scenarios are deferred
until the parent T16 plan lands.

https://claude.ai/code/session_01EqJEADzH9yjSuoP5L9js8i
2026-05-06 22:57:21 +00:00
Vitor Pamplona 32e578dcc8 Merge pull request #2754 from vitorpamplona/claude/fix-jvm-test-timeout-oBEdj
Fix NestViewModelTest hanging by properly tearing down VMs
2026-05-06 18:37:24 -04:00
Claude 4c13b2be5c fix(commons): dispose NestViewModel between tests so :commons:jvmTest stops hanging
NestViewModel.connect() launches an infinite cliff-detector loop in
viewModelScope (`while(true) { delay(...) }`). The existing tests in
NestViewModelTest call connect() but never disconnect/onCleared, so
that loop stays alive. Under runTest's virtual scheduler each delay
returns instantly, the loop spins millions of iterations per second of
real time, and runTest never reaches the idle state — every test
wedges until the per-test deadline (60 s × 17 tests => :commons:jvmTest
hangs for ~17 minutes).

Wrap each test body with runVmTest, which runs the body inside a
try/finally that calls disconnect() on every VM created by
newViewModel before runTest tries to drain. teardown() cancels
cliffDetectorJob, the scheduler becomes idle, and the test returns.
A 10 s runTest timeout is the safety net — healthy tests now finish
in milliseconds, and tripping the timeout signals a new viewModelScope
coroutine that needs its own teardown call.

Verified: :commons:jvmTest --rerun-tasks now completes in 52 s
(409 tests, 0 failures); NestViewModelTest's 17 tests run in 0.083 s
total versus the previous indefinite hang.

https://claude.ai/code/session_01R9wUxnRJrr299W8TzRyFs7
2026-05-06 22:34:35 +00:00
Vitor Pamplona c7b0dc5af4 Merge pull request #2753 from vitorpamplona/claude/fix-green-circle-ui-H7zsK
Fix avatar glow/ring clipping and energy-gate speaking indicator
2026-05-06 18:25:46 -04:00
Claude 30ea809a7a Merge remote-tracking branch 'origin/main' into claude/fix-green-circle-ui-H7zsK
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt
2026-05-06 22:23:33 +00:00
Vitor Pamplona d3abf12ac7 Merge pull request #2752 from vitorpamplona/claude/fix-amethyst-connection-loss-h34CQ
Replace cliff-detector cooldown with per-attempt backoff schedule
2026-05-06 17:34:06 -04:00
Claude 832ad78a6e fix(nests): green speaking ring no longer cropped + gates on actual speech
The full-screen Nest stage's green speaking indicator had two issues:

1. **Cropping at the top.** The glow halo (drawBehind, up to MAX_GLOW_RADIUS
   past the avatar) and detached outer ring rendered onto the avatar's own
   draw layer, which only had the avatar's bounds (75dp circle). The
   surrounding stage Surface (RoundedCornerShape(20dp)) clipped the topmost
   pixels of the first row's glow. Wrap the avatar+badges in an outer Box
   that reserves [ringPadding] (max of MAX_GLOW_RADIUS / OUTER_RING_GAP +
   OUTER_RING_MAX_WIDTH) and move the drawBehind there, drawing relative to
   the inner avatar circle. Badge corner-alignment stays on the inner Box
   so role/hand/mic badges still anchor to the avatar.

2. **Lit up on mic-on, not on actual speech.** `onSpeakerActivity` was
   called once per MoQ object received — i.e. every ~20 ms while the mic
   was open, regardless of whether the frame contained voice, silence, or
   room noise. That meant the green ring was effectively a "mic is on"
   indicator. Split the heartbeat (kept in `onSpeakerActivity`, which
   still drives the cliff detector and clears the connecting overlay)
   from the speaking detector (now in `onAudioLevel`, gated on the
   decoded peak amplitude clearing SPEAKING_LEVEL_THRESHOLD = 0.06,
   ~-24 dBFS). The existing 250 ms expiry job gives natural hysteresis
   between syllables.
2026-05-06 21:32:44 +00:00
Claude 580aaf0201 fix(nests): replace flat 30s cliff cooldown with consecutive-failed-recycle backoff
Production trace showed a single failed recycle (relay accepted SUBSCRIBE
but never opened uni-streams) leaving the user with ~22s of dead air —
the 30s cooldown blocked any retry while audio never recovered, then
they'd give up. The cooldown couldn't tell "recovered then re-stalled"
(safe to wait) from "recycle did nothing" (need to retry sooner).

Replace with a per-attempt backoff: 0 → 5s → 12s → 24s → 30s cap. Reset
to attempt 0 on any real frame after the recycle, so a recover-then-
restall always re-fires immediately. Cumulative wall-clock to 4 recycles
goes from "4 in 30s" (the moq-rs wedge case) to "4 in 41s", protecting
the relay while letting the typical single-failure case retry inside 5s.

Also stop overwriting `lastFrameAt[pubkey]` on recycle: keeping the real
last-frame timestamp is what lets the predicate distinguish the two
cases via `lastFrameAt > lastRecycleAt`.

Hardening on the leave path:
- mark `closed` @Volatile so the cliff-detector finally block reliably
  observes the value set by `leave()` / `onCleared()` (visible in the
  trace as `cliff-detector EXITED ... closed=false` after a Leave press).
- replace `runCatching { recycleSession() }` with explicit
  `catch (CancellationException)` re-throw so a Leave during recycle
  exits promptly rather than running one extra loop iteration.

https://claude.ai/code/session_015euGg6AERTRGj7HYysKnLV
2026-05-06 21:26:07 +00:00
Vitor Pamplona d854d75d7a Merge pull request #2751 from vitorpamplona/claude/stream-priority-followup-5ugk0
Implement stream priority scheduling for QUIC writer
2026-05-06 16:38:34 -04:00
Claude 72295915de fix(quic): tier-local round-robin so priority survives every drain
The previous priority-then-round-robin shape applied the rotating
start-index globally over the sorted list, so the cross-tier order
flipped on alternate drains: drain N had high-priority first, drain
N+1 advanced streamRoundRobinStart and had low-priority first. The
priority hint was silently defeated under any sustained traffic.

Replace it with strict priority across tiers + round-robin only
within each same-priority tier. Higher tiers always drain ahead of
lower ones; same-priority peers continue to take turns via the
existing rotating start. Default-priority callers see no behaviour
change (single tier, identical rotation semantics).

Tighten the test: drain twice and assert the higher-priority stream
emits first on BOTH drains — the regression case that the single-
drain version of the test missed. Add a regression guard for the
same-priority round-robin so a future refactor can't silently
serialise on the first stream.

https://claude.ai/code/session_01KWdr4RjVvyYZfEuPVaQfUa
2026-05-06 20:34:39 +00:00
Claude f1034b1f53 feat(quic): priority-aware stream scheduling for moq-lite groups
Bias the QUIC connection writer's drain loop toward higher-priority
streams so moq-lite group streams with newer (higher) sequence numbers
drain ahead of older ones under congestion. Implements the T11.3
follow-up flagged in nestsClient/plans/2026-05-06-stream-priority-
followup.md (now removed).

QuicStream gets a `@Volatile var priority: Int = 0`. The writer's
streamsView iteration is replaced by a stable sortedByDescending pass
so same-priority streams keep their existing rotating-start round
robin while higher-priority tiers always drain first.

WebTransportWriteStream gains a `setPriority(Int)` hook; the QUIC-
backed adapter forwards to the underlying QuicStream, while the
in-memory test fakes treat it as a no-op. MoqLiteSession.openGroupStream
calls `uni.setPriority(sequence)` (saturating to Int.MAX_VALUE) to
mirror moq-rs's `Publisher::serve_group`.

Tests: a new InMemoryQuicPipe.decryptClientApplicationFrames helper
walks past coalesced long-header packets to surface 1-RTT frames,
which lets QuicConnectionWriterTest assert that the higher-priority
stream's StreamFrame lands first inside a single drained packet.

https://claude.ai/code/session_01KWdr4RjVvyYZfEuPVaQfUa
2026-05-06 20:25:01 +00:00
Vitor Pamplona e144226eee Merge pull request #2750 from vitorpamplona/claude/debug-audio-dropout-n0g6Z
Add audio format negotiation and cliff detection for Nests
2026-05-06 16:07:59 -04:00
Claude 28358b4141 refactor(nests): extract ActiveSubscription + plan deferred manager refactor (Audit-9, Audit-14)
Audit-9: NestViewModel.kt is 2112 lines and growing, ~1000 of which
are subscription-lifecycle state machine concerns intertwined with
the room-level public API. Pulling out the full
`NestSubscriptionManager` is a multi-week refactor with subtle
coupling (catalog readiness affects spinner state, mute has effective
+ per-speaker flavours, expiry jobs need the parent scope) and
warrants its own focused review pass.

For now, take the small tractable subset:

  - Extract `ActiveSubscription` from a `private inner class` in
    NestViewModel to its own file as `internal class
    ActiveSubscription` in the same package. The class is purely
    state-holding (handle, roomPlayer, player, isPlaying); zero VM
    coupling beyond the slot map's value type. Same visibility for
    NestViewModel callers; one less private helper class buried
    1500 lines into NestViewModel.kt.

  - File `commons/plans/2026-05-06-nest-subscription-manager-extraction.md`
    documents the deferred full extraction: target shape, state
    that moves, methods that move, what stays in VM, why deferred,
    when to land. Picks up the next person who opens NestViewModel.kt
    rather than leaving them to re-derive the rationale.

Audit-14: T11.3 (stream priority for moq-lite group uni streams) was
deferred from the T11 commit (drop bestEffort=true) because the
:quic-side change touches the writer's hot path and warrants its own
review pass. New file `nestsClient/plans/2026-05-06-stream-priority-followup.md`
spells out:

  - Why: bestEffort=true was incidentally biasing drain order toward
    newer groups; without it, the writer's round-robin order can
    serve a stale group when a fresh one is more useful.
  - Target shape: `QuicStream.priority` field, sortedByDescending
    in the writer's send-frame loop, `WebTransportWriteStream.setPriority`
    pass-through, `MoqLiteSession.openGroupStream` calls
    setPriority(sequence). With code sketches.
  - Test: pin iteration order via the writer's emitted-frames tape.
  - Risk profile: starvation, perf cost of per-pass sort, compat.
  - When to land: after interop verification stabilises.

No code changes in this commit beyond the ActiveSubscription move.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 20:04:56 +00:00
Claude e9d19e5de4 refactor(nests): extract MoqLiteFrame + handles + publisher interface (Audit-8)
`MoqLiteSession.kt` was 1526 lines mixing the session state machine
(announce pump, subscribe pump, group-stream demux, publisher state)
with the public types its API hands back to consumers. The session-
internal `PublisherStateImpl` inner class is tightly coupled to the
session's outer scope (state lock, transport, scope.launch, openGroupStream)
and is left in place — extracting it is a separate refactor with its
own design choices.

Extract the standalone caller-facing types:

  - `MoqLiteFrame.kt` — the `MoqLiteFrame` data class with its
    custom `equals` / `hashCode` for ByteArray content equality.
  - `MoqLiteHandles.kt` — `MoqLiteSubscribeHandle`,
    `MoqLiteAnnouncesHandle`, and `MoqLiteSubscribeException`.
    All three are "what the caller gets back from a subscribe /
    announce" + "how protocol-level rejections surface."
  - `MoqLitePublisherHandle.kt` — the public publisher interface
    that the session's internal `PublisherStateImpl` implements.
    `internal constructor` on the handles keeps them un-instantiable
    outside the package.

Same package, same visibility, no call-site changes needed.
`MoqLiteSession.kt` shrinks from 1526 to 1372 lines, focused on
session lifecycle + the inner `PublisherStateImpl`. Tests +
spotless green.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:50:24 +00:00
Claude 6f649e76d8 refactor(nests): extract MoqLiteBroadcastHandle + HotSwappablePublisherSource (Audit-10)
`MoqLiteNestsSpeaker.kt` mixed three top-level concerns at 387 lines:

  - The `MoqLiteNestsSpeaker` class itself — the speaker entry point
    that builds a publisher + broadcaster pair on
    `startBroadcasting`.
  - `MoqLiteBroadcastHandle` — internal `BroadcastHandle`
    implementation tying the broadcaster, audio publisher, and
    catalog publisher together with a fixed-order shutdown.
  - `HotSwappablePublisherSource` — internal interface that lets
    `ReconnectingNestsSpeaker.runHotSwapIteration` retarget a
    long-lived broadcaster onto fresh moq-lite session publishers
    without restarting the AudioRecord / Opus encoder pipeline.

The handle and the interface are independently reachable from
`ReconnectingNestsSpeaker` and have no behavioural coupling to
`MoqLiteNestsSpeaker` beyond a `parent` reference (handle) or an
`as?` cast (interface). Move each to its own file:

  - `MoqLiteBroadcastHandle.kt` (109 lines).
  - `HotSwappablePublisherSource.kt` (62 lines).

`MoqLiteNestsSpeaker.kt` is now 276 lines focused on the speaker
class. Same package, same `internal` visibility — no call-site
changes needed elsewhere. Tests + spotless green.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:47:08 +00:00
Claude fb7b6b7cd9 refactor(nests): split MoqLiteMessages.kt by concern (Audit-11)
The original `MoqLiteMessages.kt` mixed three categories of declaration:

  - One `MoqLiteAlpn` object — the WebTransport sub-protocol
    advertisement strings.
  - Four enums — `MoqLiteControlType`, `MoqLiteDataType`,
    `MoqLiteAnnounceStatus`, `MoqLiteSubscribeResponseType` — that
    are wire-format discriminators the codec reads at message
    boundaries to choose which body to decode.
  - Eight data classes / objects — `MoqLiteAnnouncePlease`,
    `MoqLiteAnnounce`, `MoqLiteSubscribe`, `MoqLiteSubscribeOk`,
    `MoqLiteSubscribeDrop`, `MoqLiteSubscribeDropCode`,
    `MoqLiteGroupHeader`, `MoqLiteProbe` — the body shapes themselves.

317 lines, three concerns, one file. Split by responsibility:

  - `MoqLiteAlpn.kt` — the ALPN object alone.
  - `MoqLiteControlCodes.kt` — the four discriminator enums.
  - `MoqLiteMessages.kt` — body data classes only.

Same package, same visibility, identical wire behaviour. Imports
across the codebase resolve to the new locations automatically
since everything stayed in `com.vitorpamplona.nestsclient.moq.lite`.
Tests pass unchanged.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:43:45 +00:00
Claude a96ec9db00 chore(nests): standardise Log import in audio-pipeline files
Audit-12 cleanup. The audio-pipeline sources mixed two import styles:
some files used `com.vitorpamplona.quartz.utils.Log.d/w/e` fully-
qualified (NestPlayer's 13 sites, AudioTrackPlayer's 8, two each in
the encoder/decoder, three in NestMoqLiteBroadcaster) while
AudioRecordCapture had already migrated to a top-level
`import com.vitorpamplona.quartz.utils.Log`. The lambda overload
(`Log.d(tag) { lazyMessage }`) is what the find-non-lambda-logs
skill enforces and is the only style the rest of the codebase uses;
the fully-qualified form is verbose noise on every call site.

Add the `import com.vitorpamplona.quartz.utils.Log` line to each
file and rewrite call sites to bare `Log.d` / `Log.w` / `Log.e`. No
behaviour change; logs still go through PlatformLog with the same
tag and lazy-message contract. Five files updated, ~30 call sites.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:38:19 +00:00
Vitor Pamplona 2d8701f35a Merge pull request #2749 from vitorpamplona/claude/debug-moq-streams-hDRPA
Plan: cross-stack interop test (T16)
2026-05-06 15:38:15 -04:00
Claude 8a49486b37 fix(nests): G4+G5 plumb catalog sampleRate through decoder + AudioTrack
G4: Audit-9 (catalog-driven decoder reconfig) plumbed numberOfChannels
end-to-end but left sampleRate hardcoded at AudioFormat.SAMPLE_RATE_HZ
in MediaCodecOpusDecoder.buildOpusIdHeader / buildFormat and in
AudioTrackPlayer's MinBufferSize / 250 ms target / setSampleRate
calls. For Opus this is benign in practice — Codec2 always emits
48 kHz PCM regardless of OpusHead inputSampleRate — but hardcoding
the constant means a future codec or container variant whose
decoder DOES respect input sample rate would mis-clock playback.
And the OpusHead identification header should match what the
catalog declares either way.

Thread sampleRate alongside channelCount through every layer:

  - MediaCodecOpusDecoder constructor takes
    `sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ`. buildFormat
    and buildOpusIdHeader both take it as a parameter.

  - AudioTrackPlayer constructor takes the same. Used in
    AudioTrack.getMinBufferSize, the 250 ms-equivalent target
    bytes calculation (`(sampleRate / 4) * BYTES_PER_SAMPLE *
    channelCount`), AndroidAudioFormat.Builder.setSampleRate, and
    the diagnostic log.

  - decoderFactory and playerFactory in NestViewModel become
    `(channelCount: Int, sampleRate: Int) -> ...`.

  - awaitDecoderChannelCount → awaitAudioPipelineConfig, returning
    a private `AudioPipelineConfig(channelCount, sampleRate)`
    struct so openSubscription handles both fields uniformly.
    `sampleRate` falls back to AudioFormat.SAMPLE_RATE_HZ on
    timeout / non-positive declaration with a warning log.

  - NestViewModelFactory + NestViewModelTest updated to the
    two-arg factory shape.

G5: documented the SUBSCRIBE_BUFFER safety budget on
CATALOG_AWAIT_TIMEOUT_MS's kdoc. With the current 500 ms timeout +
SUBSCRIBE_BUFFER = 64-frame DROP_OLDEST flow + 50 fps Opus, at
most 25 frames buffer during the wait — leaving ≥ 39 frames of
margin (≈ 780 ms) before the oldest frame would be evicted. Even
at the production framesPerGroup = 50 (1 group/sec) cadence this
never trips during normal startup. No code change; just pinning
the rationale so a future timeout bump is checked against the
buffer size.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:36:05 +00:00
Claude 1f2532d611 docs(nests): plan T16 cross-stack interop test (no Docker)
Spec for an end-to-end interop suite that verifies Amethyst is
intelligible to and can hear the canonical NostrNests stack without
Docker. Two P0 reference stacks:

  - Rust path via the kixelated/moq `hang` crate (catches wire-shape
    regressions cheaply)
  - Browser path via @moq/watch + @moq/publish in headless Chromium
    driven by Playwright (catches Chromium QUIC, WebCodecs, and
    AudioWorklet quirks the Rust path can't see)

Architecture: native moq-relay subprocess (cargo build), in-process
ES256 JWT minter (replaces moq-auth Node sidecar), self-signed P-256
TLS cert, native cargo binaries for hang-listen / hang-publish /
udp-loss-shim, bun static server hosting the browser harness.

Test matrix I1-I15 covers every audit-branch wire fix (T1-T14) plus
the framesPerGroup=50 + WebCodecs warmup interactions only the
browser stack exercises. Phases 1-2 + 4 are the 3-day P0 deliverable;
Phases 3 + 5 are the P1 hot-swap + transport-robustness follow-up.

Self-contained: another agent should be able to execute Phases 1-2 +
4 end-to-end from this spec alone.

https://claude.ai/code/session_01FZPQiniPmb88pwY9i78eqA
2026-05-06 19:25:38 +00:00
Vitor Pamplona e378d9ea96 Merge pull request #2748 from vitorpamplona/claude/fix-windows-vlc-download-KKsqW
Pre-fetch VLC and UPX archives to improve build reliability
2026-05-06 15:18:32 -04:00
Claude 00194d44a4 ci(desktop): pre-fetch VLC + UPX with curl --retry to dodge videolan flakes
Replace the nick-fields/retry wrapper around the Gradle invocation with a
curl-based pre-fetch step. The vlc-setup plugin writes its downloads to
${gradleUserHomeDir}/vlcSetup/ with overwrite(false), so dropping the
archives there ahead of time turns vlcDownload / upxDownload into no-ops.

curl --retry-all-errors --retry-max-time 900 tolerates a sustained
get.videolan.org outage far better than the plugin's de.undercouch
Download (retries(4) + 5min readTimeout, which still hit
SocketTimeoutException on Windows).

The actions/cache step at ~/.gradle/vlcSetup still captures the result
for subsequent runs; the prefetch only does real work on cache miss.
The in-build retries(4) in desktopApp/build.gradle.kts stays as a third
line of defense.

URLs and on-disk paths are read straight from the plugin source
(ir.mahozad.vlc-setup 0.1.0); macOS skips UPX because UPX cannot compress
.dylib files.
2026-05-06 19:16:48 +00:00
Claude 75f572ba3d test+fix(nests): pin stripLegacyTimestampPrefix; primaryAudio filters to legacy
Two audit-pass gaps:

G1 — `MoqLiteNestsListener.Companion.stripLegacyTimestampPrefix`
is the entire receive-side wire-format converter for audio frames
(every web speaker's Opus packet flows through it) and had zero
unit tests. A regression here is invisible to users — silent decode
failure of every web speaker — and to interop tests, because both
sides agree on the same bug. New StripLegacyTimestampPrefixTest pins:

  - all four QUIC varint-length tiers (1 / 2 / 4 / 8 bytes), one
    test per tier with a representative timestamp value plus a
    tag-byte assertion so the test fails loudly if Varint.encode's
    tier-selection changes.
  - empty-payload and malformed-payload fast paths (returns
    payload unchanged, same instance — no allocation).
  - round-trip against `NestMoqLiteBroadcaster`'s exact wire
    shape (`varint(timestamp_us) + raw_opus_packet`) across five
    timestamp values spanning all four tiers.

G6 — `RoomSpeakerCatalog.primaryAudio()` previously returned
`audio.renditions.values.firstOrNull()` with no container filter.
A future publisher that emits CMAF before legacy in iteration
order would surface the CMAF rendition, and our decoder pipeline
would then feed CMAF MOOF/MDAT bytes to its legacy
`varint(ts)+opus` parser and decode garbage. Filter to
`container.kind == Container.LEGACY_KIND` so:

  - mixed CMAF+legacy publishers always surface the legacy entry
    regardless of map iteration order
  - CMAF-only publishers correctly return null (caller falls
    back to "unknown codec / no audio")
  - publishers that omit `container` entirely also return null —
    treating "no container declared" as "legacy by default" would
    silently mis-decode a CMAF-only publisher that just forgot
    to spell out `container.kind`

LEGACY_KIND const lives on `Container.Companion` so call sites
share one canonical spelling. Three new test cases pin the new
filter behaviour (mixed iteration order, CMAF-only, missing
container). The pre-existing `toleratesUnknownKeys` test is
updated to declare a legacy container — the unknown-key
tolerance we're checking is about unrecognised siblings, not
container-presence.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:09:24 +00:00
Claude cb428be28e ci(desktop): wrap desktop build in retry to survive vlcDownload flakes
The Windows leg of the Test/Build workflow has been intermittently failing
with SocketTimeoutException inside :desktopApp:vlcDownload, even after the
in-build retry budget (retries(4) + 5min readTimeout in
desktopApp/build.gradle.kts) and the actions/cache of ~/.gradle/vlcSetup.
When get.videolan.org is unreachable for long enough to exhaust the inner
retries, the whole job dies on a cache miss.

Wrap the Test+Build step in nick-fields/retry@v4 (max_attempts: 2,
timeout_minutes: 45) so one outer retry can ride out a sustained
videolan.org outage. This mirrors the proven pattern already used in
create-release.yml for the release artifact build.
2026-05-06 18:57:01 +00:00
Claude a94d12638f perf(nests): bound encoder CSD loop, single-alloc audio framing, cache catalog JSON
Three audit follow-ups, all in the audio publish hot path:

Audit-3: MediaCodecOpusEncoder.encode could busy-loop on a buggy
encoder that emits BUFFER_FLAG_CODEC_CONFIG on every dequeue. The
existing format-change path has a one-shot `formatChangeAbsorbed`
guard for exactly this reason; the new CSD-skip path didn't. Cap
consecutive CSD skips per encode call at MAX_CSD_SKIPS_PER_CALL=4
(generous: Codec2 emits 1 OpusHead or 2 OpusHead+OpusTags in
practice). On overshoot, log a warning and return ByteArray(0) —
the broadcaster's `if (opus.isEmpty()) continue` already handles
that contract.

Audit-6: NestMoqLiteBroadcaster's per-frame send path allocated
twice per Opus packet — once for the timestamp Varint and once
for the concatenated `varint+opus` payload. At 50 fps × N
speakers that's measurable young-gen pressure. Switch to the
same single-allocation pattern PublisherStateImpl.send already
uses: compute Varint.size(timestampUs), allocate the final
payload buffer once, write the varint directly into it via
Varint.writeTo, then copy the opus bytes. Drops audio-thread
allocations from 3/frame to 1/frame (the third was the wrap in
PublisherStateImpl.send, already optimised).

Audit-7: MoqLiteNestsSpeaker.startBroadcasting and
ReconnectingNestsSpeaker.runHotSwapIteration both encode the same
fixed catalog JSON on every call — once per broadcast start and
once per JWT-refresh hot-swap iteration. Cache the encoded bytes
on MoqLiteHangCatalog.Companion.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES
so the kotlinx.serialization run happens at class-init time and
both call sites read a static reference. Trivial perf, but
co-locates the "default Amethyst publish shape" in one place.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 18:37:19 +00:00
Claude ade8da3b5b fix(nests): NestPlayer keeps existing decoder when boundary factory throws
Audit-1: the publisher-boundary rebuild path released the old decoder
BEFORE asking the factory for a replacement (`runCatching {
decoder.release() }; decoder = factory()`). If `factory()` threw —
MediaCodec contention, audio policy denial mid-session, etc. — the
released decoder stayed assigned to the field and every subsequent
`decoder.decode(payload)` would throw `IllegalStateException` with
no recovery path. The room would silently fail for that subscription
until torn down.

Build the replacement first; only release the old one and swap on
success. On factory failure, log and keep the existing decoder
running. Cross-publisher Opus predictor state is wrong for one
group but at least audio keeps playing.

Audit-4+5: companion test cleanup —

  - Drop the `byteArrayOf(0x0A) + it` / `0x0B` dead expressions in
    the FakeOpusDecoder closures of the existing boundary-rebuild
    test. They allocated a ByteArray and concatenated, then threw
    the result away.

  - Drop the local `IntBox` helper and use
    `java.util.concurrent.atomic.AtomicInteger` like the rest of
    the codebase does (`MoqLiteSession`, `MoqLiteNestsListener`).
    Same semantics, no new vocabulary.

New test pins the dangling-field fix:
`publisher_boundary_keeps_old_decoder_when_factory_throws` flows
two MoqObjects with different trackAliases through a NestPlayer
whose factory always throws; asserts both frames decode through
the original decoder and the original decoder is released exactly
once on `stop()`.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 18:33:24 +00:00
Claude 7e76ab1139 fix(nests): T11 drop bestEffort=true on moq-lite group uni streams
`MoqLiteSession.openGroupStream` was opening each group's QUIC uni
stream with `bestEffort = true`. `:quic`'s `SendBuffer.markLost` with
that flag drops lost STREAM ranges WITHOUT retransmit AND WITHOUT
`RESET_STREAM` (`quic/src/commonMain/kotlin/com/vitorpamplona/quic/
stream/SendBuffer.kt:300-309`). The peer's `ReceiveBuffer` ends up
with chunks at `[0, P)` and `[Q, end)` and a permanent hole at
`[P, Q)`; the application's `incoming` Flow parks on the hole forever.

Web watchers (`@moq/hang` `Container.Consumer`) park their
`Group.readFrame` until the relay's 30 s `MAX_GROUP_AGE` ages the
broadcast queue out — manifesting as a 30 s silent dropout per lost
packet on lossy networks (cellular, mobile WiFi, congested home
routers). This is a real-world bug that's invisible on a clean LAN.

The reference implementation (kixelated/moq-rs `Publisher::serve_group`,
`rs/moq-lite/src/lite/publisher.rs:347-406`) writes to reliable QUIC
streams with no `set_unreliable` call — `bestEffort` was Amethyst's
private optimisation with no peer-side support. Drop it; let `:quic`
retransmit lost ranges normally. A retransmit arriving 50–150 ms late
still falls inside hang's default ~200 ms jitter buffer, so the cost
is marginal extra bandwidth on retransmits and the win is no more
silent dropouts on lossy networks.

T11.2 — orthogonality check: stream-cliff fix is independent.
Re-read `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`
and grep'd both the plan and `NestMoqLiteBroadcaster.kt` for
`bestEffort` / `best_effort` — neither references it. The cliff fix
is `framesPerGroup = 5/50` (cadence reduction); load-bearing on
stream-creation RATE, not loss handling. Drop is safe.

T11.3 (stream priority — newer groups drain first under congestion)
deferred to a follow-up commit; hooking it into `:quic`'s
send-frame loop is bigger than a one-line change and warrants its
own task. The kixelated/moq-rs publisher uses
`stream.set_priority(priority.current())` to bias the writer; without
it, our drain order under congestion is FIFO across streams rather
than newest-first, which can mean the listener catches up on a stale
group when a fresh one is more useful. Doesn't block today —
production audio rarely hits transport congestion at 1 group/sec
cadence.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 18:18:23 +00:00
Claude 4714e3c723 fix(nests): T13 reset Opus decoder on publisher boundary in NestPlayer
The reissuing-subscribe wrapper splices fresh-publisher frames into the
same `SharedFlow` that NestPlayer consumes, so across a JWT-refresh
hot-swap (speaker side) or a cliff-detector recycle (listener side)
the decoder receives a discontinuous frame stream while keeping its
Opus predictor state. Result: an audible warble at every publisher
cycle. Single-stream tests don't catch it because they never present
two distinct publishers.

Detect the boundary via `MoqObject.trackAlias` — the underlying
`MoqLiteSession.subscribe` assigns a fresh subscribeId per SUBSCRIBE,
which the listener wrapper surfaces verbatim as `trackAlias` on every
emitted object. A change between consecutive objects = new publisher.

Add an optional `decoderFactory: (() -> OpusDecoder)?` to NestPlayer.
When non-null, NestPlayer tracks `lastTrackAlias` and on a change
releases the current decoder and rebuilds via the factory. The
default `null` preserves the legacy single-decoder behaviour so
existing tests / callers that don't care about boundaries stand
unchanged.

NestViewModel.openSubscription wires the factory: a closure capturing
the catalog-derived `channelCount` so a rebuild reuses the SAME
channel layout — without that capture, a rebuild after a stereo-
publisher cycle would default to mono and silently downmix.

Two new NestPlayerTest cases pin the behaviour:
  - `publisher_boundary_rebuilds_decoder_when_factory_provided`:
    factory invoked twice (initial + boundary), first decoder
    released on boundary, second released on stop.
  - `publisher_boundary_no_op_when_factory_is_null`: legacy path
    holds onto the same decoder across trackAlias changes (unchanged
    semantics).

NestPlayer's first constructor parameter is now `initialDecoder`
(was `decoder`); positional-arg call sites are unchanged but the
named-arg call sites in the test suite are updated accordingly.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 18:00:52 +00:00
Claude be4e0b9f98 fix(nests): T12 carry audio group sequence across hot-swaps
Every JWT-refresh hot-swap reset the audio publisher's group sequence
counter to 0, because `PublisherStateImpl` always initialised
`nextSequence = 0L`. kixelated/hang's `Container.Consumer.#run`
discards any consumer with `sequence < this.#active`, so a watcher
whose `#active` had advanced to e.g. 50 from the previous session's
publisher would silently drop every group on the new session until
either `#active` rolls over or the watcher re-subscribes — audible as
"speaker goes silent for a minute every 9 minutes" (the proactive
JWT refresh window).

Carry the sequence forward end-to-end:

  - MoqLiteSession.publish gains a `startSequence: Long = 0L`
    parameter; PublisherStateImpl seeds `nextSequenceField` from it
    instead of hard-coding 0.

  - MoqLitePublisherHandle exposes a `nextSequence: Long` snapshot.
    `@Volatile`-backed so the hot-swap caller can read it without
    contending the publisher's gate; race-window between read and
    a concurrent send is sub-millisecond and would only produce a
    single duplicate sequence in the very unlikely overlap, which
    listeners tolerate.

  - HotSwappablePublisherSource.openPublisherForHotSwap takes
    `startSequence: Long = 0L`. MoqLiteNestsSpeaker passes it
    through to session.publish.

  - NestMoqLiteBroadcaster exposes its current publisher via a
    read-only `currentPublisher` so the hot-swap pump in
    ReconnectingNestsSpeaker.runHotSwapIteration can read its
    `nextSequence` before opening the replacement publisher and
    pass it as the seed.

  - Catalog publisher keeps `startSequence = 0L` (the default) —
    catalog isn't subject to the same `#active` accumulation
    because each new SUBSCRIBE triggers a fresh emit-on-subscribe
    write that resets the watcher's catalog state.

Listener-side change is none — the watcher already reads whatever
sequence we send. A new MoqLiteSessionTest pins the contract:
publishing with startSequence=42 makes the first uni stream's
GroupHeader.sequence == 42 and advances to 43 after the first send.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:51:45 +00:00