Skip to content

fix: broadcast timeout#494

Open
nogringo wants to merge 2 commits intomasterfrom
test/add-broadcast-timeout-tests
Open

fix: broadcast timeout#494
nogringo wants to merge 2 commits intomasterfrom
test/add-broadcast-timeout-tests

Conversation

@nogringo
Copy link
Collaborator

@nogringo nogringo commented Mar 14, 2026

Summary by CodeRabbit

  • Tests
    • Added broadcast reliability tests ensuring broadcasts complete within 5 seconds, including scenarios with offline or no relays.
  • Refactor
    • Converted asynchronous control flow to linear async/await for broadcast paths, simplifying flow and early-exit handling.
  • Bug Fixes
    • Improved transport null-checks and timeout handling; fixed state emission/disposal to reliably detect broadcast completion.
  • Behavior
    • Broadcasting now batches and awaits concurrent relay operations for specified and write/read relay workflows.

@coderabbitai
Copy link

coderabbitai bot commented Mar 14, 2026

📝 Walkthrough

Walkthrough

Refactors JIT broadcast control flow to use async/await and parallel broadcasts, tightens relay transport null checks and readiness handling, adjusts broadcast completion logic to use a ReplaySubject, and adds two NDK broadcast timing tests (offline-relay and zero-relay scenarios).

Changes

Cohort / File(s) Summary
Broadcast tests
packages/ndk/test/broadcast/broadcast_test.dart
Adds two timing tests validating broadcast completes within 5s with one offline bootstrap relay and that broadcasting to zero relays does not time out.
Broadcast state
packages/ndk/lib/domain_layer/entities/broadcast_state.dart
Switches state updates from BehaviorSubject to ReplaySubject(1); updates publishDone logic and ensures _checkBroadcastDone emits final state before disposing.
JIT engine flow
packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart
Converts several synchronous/then-style broadcast calls to awaited calls, adds early closes of network controller when no broadcasts remain, and inserts early returns after explicit-relays handling.
Relay-specific JIT strategies
packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_specific.dart, .../relay_jit_broadcast_own.dart, .../relay_jit_broadcast_other_read.dart
Refactors per-relay connect/reconnect flows from promise chains to async/await; preserves error handling but changes control flow to immediate checks and continue on failure.
Relay manager
packages/ndk/lib/domain_layer/usecases/relay_manager.dart
Removes timeout-side onTimeout handler, fixes relayTransport null assignment in catch, and adds null checks before transport property access and connection short-circuits.
Relay sets / broadcast orchestration
packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart
Reworks handleEventBroadcast to perform concurrent broadcasts via Future.wait for specificRelays, outbox/write-relays, and read-relays derived from Nip65; adds logging for missing Nip65 data and closes network controller if no broadcasts were produced.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant JIT as JitEngine
participant Sets as RelaySetsEngine
participant Manager as RelayManager
participant Relay as RelayTransport
participant Network as NetworkController

Client->>JIT: request broadcast(event, strategy)
JIT->>Sets: handleEventBroadcast(event, strategy)
Sets->>Manager: select & (re)connect relays (parallel)
Manager->>Relay: connect/reconnect
Relay-->>Manager: connected / failed
Manager-->>Sets: connection results
Sets->>Relay: sendToRelay (for connected transports)
Relay-->>Network: emit publish result
Network-->>Sets: publish responses aggregated
Sets->>JIT: broadcast results
JIT->>Client: return broadcast outcome
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through code at break of day,
Awaiting relays that lost their way,
I stitched async threads and made them run,
Now broadcasts finish—quick as a pun!
Tests hop in — timeout undone! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The PR title 'fix: broadcast timeout' is vague and doesn't clearly summarize the main changes. The PR contains significant refactoring of broadcast logic and concurrency patterns, along with two new tests, but the title doesn't capture these substantive changes meaningfully. Consider a more descriptive title like 'refactor: convert broadcast async/await and add timeout tests' or 'fix: broadcast timeout handling and add tests' to better reflect the scope of changes.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/add-broadcast-timeout-tests
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can enable review details to help with troubleshooting, context usage and more.

Enable the reviews.review_details setting to include review details such as the model used, the time taken for each step and more in the review comments.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/ndk/test/broadcast/broadcast_test.dart (1)

17-40: Consider extracting shared setup/assert flow into a helper.

Both tests repeat the same key generation, login, event creation, timing, and threshold assertion. A helper would reduce duplication and make future timeout-case additions easier.

Also applies to: 51-77

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ndk/test/broadcast/broadcast_test.dart` around lines 17 - 40, Tests
duplicate key generation, login, event creation, timing and timeout assertion;
extract that shared flow into a helper function (e.g.,
runBroadcastAndAssertWithin) that performs Bip340.generatePrivateKey(), calls
ndk.accounts.loginPrivateKey(privkey:..., pubkey:...), constructs the
Nip01Event(pubKey:..., kind:1, content:'', tags:[]), starts a Stopwatch, invokes
ndk.broadcast.broadcast(nostrEvent: event), awaits
broadcast.broadcastDoneFuture, stops the stopwatch and asserts
stopwatch.elapsedMilliseconds is lessThan a given threshold; replace the
repeated blocks in both test locations with calls to this helper and pass the
threshold as a parameter.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/ndk/test/broadcast/broadcast_test.dart`:
- Around line 6-43: The test currently calls await ndk.destroy() only on the
happy path; wrap the test body so Ndk cleanup always runs by ensuring
ndk.destroy() is invoked in a finally block (or use test framework tearDown)
after creating the Ndk instance, so any exceptions from
broadcast.broadcast/broadcastDoneFuture or failed assertions still call
ndk.destroy(); locate the Ndk instantiation (Ndk(...)), the broadcast invocation
(ndk.broadcast.broadcast and broadcastDoneFuture) and ensure destroy() is called
in finally/tearDown.

---

Nitpick comments:
In `@packages/ndk/test/broadcast/broadcast_test.dart`:
- Around line 17-40: Tests duplicate key generation, login, event creation,
timing and timeout assertion; extract that shared flow into a helper function
(e.g., runBroadcastAndAssertWithin) that performs Bip340.generatePrivateKey(),
calls ndk.accounts.loginPrivateKey(privkey:..., pubkey:...), constructs the
Nip01Event(pubKey:..., kind:1, content:'', tags:[]), starts a Stopwatch, invokes
ndk.broadcast.broadcast(nostrEvent: event), awaits
broadcast.broadcastDoneFuture, stops the stopwatch and asserts
stopwatch.elapsedMilliseconds is lessThan a given threshold; replace the
repeated blocks in both test locations with calls to this helper and pass the
threshold as a parameter.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5faa1b96-231f-4dbc-8479-47488ca37fcf

📥 Commits

Reviewing files that changed from the base of the PR and between f2cdc15 and c0143d8.

📒 Files selected for processing (1)
  • packages/ndk/test/broadcast/broadcast_test.dart

Comment on lines +6 to +43
test(
'broadcast should complete quickly even with one offline relay',
() async {
final ndk = Ndk(
NdkConfig(
eventVerifier: Bip340EventVerifier(),
cache: MemCacheManager(),
bootstrapRelays: ["ws://localhost:25565"],
),
);

final keyPair = Bip340.generatePrivateKey();
ndk.accounts.loginPrivateKey(
privkey: keyPair.privateKey!,
pubkey: keyPair.publicKey,
);

final event = Nip01Event(
pubKey: keyPair.publicKey,
kind: 1,
content: '',
tags: [],
);

final stopwatch = Stopwatch()..start();

final broadcast = ndk.broadcast.broadcast(nostrEvent: event);
await broadcast.broadcastDoneFuture;

stopwatch.stop();

expect(
stopwatch.elapsedMilliseconds,
lessThan(5000),
);

await ndk.destroy();
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Ensure Ndk cleanup runs even on failures.

await ndk.destroy() (Line 42, Line 79) is only reached on the happy path. If broadcastDoneFuture throws or an assertion fails, cleanup is skipped and can leak state across tests.

💡 Proposed fix
   test(
     'broadcast should complete quickly even with one offline relay',
     () async {
       final ndk = Ndk(
         NdkConfig(
           eventVerifier: Bip340EventVerifier(),
           cache: MemCacheManager(),
           bootstrapRelays: ["ws://localhost:25565"],
         ),
       );
+      addTearDown(() async => ndk.destroy());

       final keyPair = Bip340.generatePrivateKey();
       ndk.accounts.loginPrivateKey(
         privkey: keyPair.privateKey!,
         pubkey: keyPair.publicKey,
       );
@@
       expect(
         stopwatch.elapsedMilliseconds,
         lessThan(5000),
       );
-
-      await ndk.destroy();
     },
   );
@@
   test(
     'broadcast to 0 relay should not time out',
     () async {
       final ndk = Ndk.emptyBootstrapRelaysConfig();
+      addTearDown(() async => ndk.destroy());
@@
       expect(
         stopwatch.elapsedMilliseconds,
         lessThan(5000),
       );
-
-      await ndk.destroy();
     },
   );

Also applies to: 46-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ndk/test/broadcast/broadcast_test.dart` around lines 6 - 43, The
test currently calls await ndk.destroy() only on the happy path; wrap the test
body so Ndk cleanup always runs by ensuring ndk.destroy() is invoked in a
finally block (or use test framework tearDown) after creating the Ndk instance,
so any exceptions from broadcast.broadcast/broadcastDoneFuture or failed
assertions still call ndk.destroy(); locate the Ndk instantiation (Ndk(...)),
the broadcast invocation (ndk.broadcast.broadcast and broadcastDoneFuture) and
ensure destroy() is called in finally/tearDown.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_other_read.dart`:
- Around line 91-94: The code uses
relayManager.connectedRelays.firstWhere((element) => element.url == relayUrl)
which can throw a StateError if no relay matches; wrap that lookup in a
try-catch (or use firstWhere with orElse) to handle the missing-relay case, and
only call sendToRelay(relay: relay) when a relay is found; ensure the catch logs
or handles the error similarly to relay_jit_broadcast_specific.dart so behavior
is consistent with other strategies.

In
`@packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_own.dart`:
- Around line 82-85: The relay lookup using
relayManager.connectedRelays.firstWhere (the block that finds a relay by
relayUrl before calling sendToRelay) can throw StateError if no match is found;
wrap that lookup in a try-catch (matching the pattern used in
relay_jit_broadcast_specific.dart) so if the relay isn't present after
connectRelay we catch the error, skip/continue the loop and do not call
sendToRelay, and optionally log the missing-relay case; update the code around
firstWhere and sendToRelay to handle the exception gracefully.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 35785250-d0da-418e-8cdd-e5cdf17196ec

📥 Commits

Reviewing files that changed from the base of the PR and between c0143d8 and 0c2e56d.

📒 Files selected for processing (7)
  • packages/ndk/lib/domain_layer/entities/broadcast_state.dart
  • packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart
  • packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_other_read.dart
  • packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_own.dart
  • packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_specific.dart
  • packages/ndk/lib/domain_layer/usecases/relay_manager.dart
  • packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart

Comment on lines +91 to +94
final relay = relayManager.connectedRelays
.firstWhere((element) => element.url == relayUrl);

sendToRelay(relay: relay);
});
sendToRelay(relay: relay);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for relay lookup after connection.

Same issue as relay_jit_broadcast_own.dart: firstWhere can throw StateError if the relay isn't found in connectedRelays. This should be wrapped in try-catch for consistency with relay_jit_broadcast_specific.dart.

Proposed fix: wrap in try-catch for consistency
       if (!success.first) {
         relayManager.failBroadcast(
           eventToPublish.id,
           relayUrl,
           "connection failed",
         );
         continue;
       }
-      final relay = relayManager.connectedRelays
-          .firstWhere((element) => element.url == relayUrl);
-
-      sendToRelay(relay: relay);
+      try {
+        final relay = relayManager.connectedRelays
+            .firstWhere((element) => element.url == relayUrl);
+        sendToRelay(relay: relay);
+      } catch (e) {
+        relayManager.failBroadcast(
+          eventToPublish.id,
+          relayUrl,
+          "relay not found after connection",
+        );
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final relay = relayManager.connectedRelays
.firstWhere((element) => element.url == relayUrl);
sendToRelay(relay: relay);
});
sendToRelay(relay: relay);
if (!success.first) {
relayManager.failBroadcast(
eventToPublish.id,
relayUrl,
"connection failed",
);
continue;
}
try {
final relay = relayManager.connectedRelays
.firstWhere((element) => element.url == relayUrl);
sendToRelay(relay: relay);
} catch (e) {
relayManager.failBroadcast(
eventToPublish.id,
relayUrl,
"relay not found after connection",
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_other_read.dart`
around lines 91 - 94, The code uses
relayManager.connectedRelays.firstWhere((element) => element.url == relayUrl)
which can throw a StateError if no relay matches; wrap that lookup in a
try-catch (or use firstWhere with orElse) to handle the missing-relay case, and
only call sendToRelay(relay: relay) when a relay is found; ensure the catch logs
or handles the error similarly to relay_jit_broadcast_specific.dart so behavior
is consistent with other strategies.

Comment on lines +82 to +85
final relay = relayManager.connectedRelays
.firstWhere((element) => element.url == relayUrl);

sendToRelay(relay: relay);
});
sendToRelay(relay: relay);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for relay lookup after connection.

firstWhere throws StateError if no matching element is found. Unlike relay_jit_broadcast_specific.dart (lines 64-74), this code lacks a try-catch wrapper. If connectRelay succeeds but the relay isn't in connectedRelays, the loop will throw instead of continuing gracefully.

Proposed fix: wrap in try-catch for consistency
       final success = await relayManager.connectRelay(
         dirtyUrl: relayUrl,
         connectionSource: ConnectionSource.broadcastOwn,
       );
       if (!success.first) {
         relayManager.failBroadcast(
           eventToPublish.id,
           relayUrl,
           "connection failed",
         );
         continue;
       }
-      final relay = relayManager.connectedRelays
-          .firstWhere((element) => element.url == relayUrl);
-
-      sendToRelay(relay: relay);
+      try {
+        final relay = relayManager.connectedRelays
+            .firstWhere((element) => element.url == relayUrl);
+        sendToRelay(relay: relay);
+      } catch (e) {
+        relayManager.failBroadcast(
+          eventToPublish.id,
+          relayUrl,
+          "relay not found after connection",
+        );
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_broadcast_strategies/relay_jit_broadcast_own.dart`
around lines 82 - 85, The relay lookup using
relayManager.connectedRelays.firstWhere (the block that finds a relay by
relayUrl before calling sendToRelay) can throw StateError if no match is found;
wrap that lookup in a try-catch (matching the pattern used in
relay_jit_broadcast_specific.dart) so if the relay isn't present after
connectRelay we catch the error, skip/continue the loop and do not call
sendToRelay, and optionally log the missing-relay case; update the code around
firstWhere and sendToRelay to handle the exception gracefully.

@nogringo nogringo changed the title test: add broadcast timeout tests fix: broadcast timeout Mar 18, 2026
@nogringo nogringo requested review from 1-leo and frnandu March 18, 2026 13:36
@codecov
Copy link

codecov bot commented Mar 25, 2026

Codecov Report

❌ Patch coverage is 72.72727% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.95%. Comparing base (f2cdc15) to head (0c2e56d).
⚠️ Report is 359 commits behind head on master.

Files with missing lines Patch % Lines
...k/lib/domain_layer/usecases/relay_sets_engine.dart 72.41% 8 Missing ⚠️
...dcast_strategies/relay_jit_broadcast_specific.dart 50.00% 4 Missing ⚠️
...b/domain_layer/usecases/jit_engine/jit_engine.dart 71.42% 2 Missing ⚠️
...ast_strategies/relay_jit_broadcast_other_read.dart 71.42% 2 Missing ⚠️
..._broadcast_strategies/relay_jit_broadcast_own.dart 71.42% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #494      +/-   ##
==========================================
+ Coverage   76.74%   76.95%   +0.21%     
==========================================
  Files         153      153              
  Lines        6407     6401       -6     
==========================================
+ Hits         4917     4926       +9     
+ Misses       1490     1475      -15     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants