feat(core): implement event system (task 07)#7
Conversation
…e (task 07) Add the event system infrastructure that connects domain events to the frontend via Tauri emit. This is the decoupling mechanism between command handlers and the UI. Backend: - TokioEventBus adapter using tokio::sync::broadcast channel - Tauri event bridge subscribing to EventBus and forwarding to webview - Event name mapping (22 DomainEvent variants → kebab-case event names) - Event payload serialization (camelCase JSON, no serde in domain) - Lag detection with tracing::warn for slow subscribers Frontend: - useTauriEvent<T> generic hook with useRef callback pattern - Race-safe cleanup with cancelled flag and .catch() on unlisten - 7 tests covering subscribe, cleanup, re-subscribe, ref pattern, rejection Tests: 145 Rust (4 new), 7 TypeScript (all new)
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughAdds a new event subsystem: a broadcast-backed Changes
Sequence DiagramsequenceDiagram
actor Domain
participant EventBus as TokioEventBus
participant Bridge as Tauri Bridge
participant Tauri as Tauri Webview
participant Hook as useTauriEvent Hook
actor Frontend
Domain->>EventBus: publish(DomainEvent)
EventBus->>Bridge: subscription callback(event)
Bridge->>Bridge: map variant -> (eventName, jsonPayload)
Bridge->>Tauri: emit(eventName, jsonPayload)
Tauri->>Hook: delivers event(payload)
Hook->>Frontend: invoke callback(payload)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Greptile SummaryThis PR adds the full event pipeline: a Confidence Score: 5/5Safe to merge; all findings are P2 style/hardening suggestions with no blocking correctness issues. The core event bus, Tauri bridge, and React hook are all correctly implemented and well-tested. The three inline comments are: a potential unhandled-rejection window in the hook (low practical impact since Tauri listen rarely fails), a redundant clone in the bridge, and a note that the bridge isn't yet wired into run() (expected for an incremental task PR). None of these affect correctness of the code as written. src/hooks/useTauriEvent.ts (unhandled rejection window) and src-tauri/src/lib.rs (bridge not wired into run()).
|
| Filename | Overview |
|---|---|
| src-tauri/src/adapters/driven/event/tokio_event_bus.rs | Implements EventBus via tokio broadcast channel with lag detection; clean tests covering subscribe/publish/broadcast/zero-capacity cases. |
| src-tauri/src/adapters/driven/event/tauri_bridge.rs | Exhaustive mapping of all 18 DomainEvent variants to kebab-case names and camelCase JSON payloads; redundant app_handle clone is a minor style issue. |
| src/hooks/useTauriEvent.ts | useRef callback pattern and cancelled flag are correct; has a window where a rejected listen promise can become unhandled if the component stays mounted. |
| src/hooks/useTauriEvent.test.ts | Good coverage of subscribe, callback dispatch, cleanup, re-subscribe, ref pattern, cancelled flag, and rejection; rejection test unmounts immediately which avoids the unhandled-rejection window. |
| src-tauri/src/lib.rs | Exports TokioEventBus and spawn_tauri_event_bridge but neither is wired into run(); the event pipeline is inert until integrated in a Tauri .setup() callback. |
| src-tauri/src/adapters/driven/event/mod.rs | Thin re-export module, no issues. |
| src-tauri/src/adapters/driven/mod.rs | Adds event module declaration alongside existing sqlite module, no issues. |
Sequence Diagram
sequenceDiagram
participant Domain as Domain Layer
participant Bus as TokioEventBus<br/>(broadcast::Sender)
participant Bridge as spawn_tauri_event_bridge<br/>(tokio task)
participant Tauri as Tauri Runtime<br/>(AppHandle::emit)
participant Hook as useTauriEvent<br/>(React hook)
Domain->>Bus: publish(DomainEvent)
Bus-->>Bridge: broadcast::Receiver::recv()
Bridge->>Bridge: event_name() + event_payload()
Bridge->>Tauri: handle.emit(name, payload)
Tauri-->>Hook: listen callback fires
Hook->>Hook: check cancelled flag
Hook->>Hook: callbackRef.current(event.payload)
Note over Hook: On unmount: cancelled=true<br/>unlistenFn?.()
Comments Outside Diff (1)
-
src-tauri/src/lib.rs, line 23-29 (link)Event bridge not wired into
run()TokioEventBusandspawn_tauri_event_bridgeare exported fromlib.rsbut neither is instantiated nor called insiderun(). As a result, the backend→frontend event pipeline described in the PR architecture is inert — the frontend'suseTauriEventhook will never receive any events untilspawn_tauri_event_bridgeis called during Tauri setup. Something like the following is needed in the.setup()callback:.setup(|app| { let event_bus = Arc::new(TokioEventBus::new(64)); spawn_tauri_event_bridge(app.handle().clone(), &*event_bus); app.manage(event_bus); Ok(()) })
If wiring is intentionally deferred to a follow-up task, a
// TODO(task-XX)comment here would prevent the gap from being overlooked.
Reviews (1): Last reviewed commit: "fix(core): resolve TypeScript type error..." | Re-trigger Greptile
src/hooks/useTauriEvent.ts
Outdated
| const unlistenPromise = listen<T>(eventName, (event) => { | ||
| if (!cancelled) { | ||
| callbackRef.current(event.payload); | ||
| } | ||
| }); | ||
| return () => { | ||
| cancelled = true; | ||
| unlistenPromise.then((fn) => fn()).catch(() => {}); |
There was a problem hiding this comment.
Unhandled rejection window before cleanup
unlistenPromise has no rejection handler attached at creation time. If listen rejects asynchronously (e.g., IPC bridge unavailable during a network hiccup) while the component stays mounted, the Promise rejection is flagged as unhandled by the runtime before the cleanup's .catch() is ever attached. The existing test side-steps this by calling unmount() synchronously right after mount, which attaches the .catch() before the microtask checkpoint.
A safe fix is to keep a separate resolved state instead of storing the raw promise:
useEffect(() => {
let cancelled = false;
let unlistenFn: (() => void) | undefined;
listen<T>(eventName, (event) => {
if (!cancelled) callbackRef.current(event.payload);
})
.then((fn) => {
if (cancelled) fn(); // already unmounted, clean up immediately
else unlistenFn = fn;
})
.catch(() => {}); // suppress listen errors eagerly
return () => {
cancelled = true;
unlistenFn?.();
};
}, [eventName]);This attaches .catch() immediately, eliminating the unhandled-rejection window regardless of how long the component stays mounted.
| pub fn spawn_tauri_event_bridge(app_handle: AppHandle, event_bus: &dyn EventBus) { | ||
| let handle = app_handle.clone(); | ||
| event_bus.subscribe(Box::new(move |event: &DomainEvent| { | ||
| let (name, payload) = to_tauri_event(event); | ||
| handle.emit(name, payload).ok(); // Best-effort, don't crash on emit failure | ||
| })); |
There was a problem hiding this comment.
app_handle is taken by value (AppHandle is Clone + Send) and then immediately cloned into handle. The original binding is never used after the clone. You can move app_handle directly into the closure and drop the intermediate binding.
| pub fn spawn_tauri_event_bridge(app_handle: AppHandle, event_bus: &dyn EventBus) { | |
| let handle = app_handle.clone(); | |
| event_bus.subscribe(Box::new(move |event: &DomainEvent| { | |
| let (name, payload) = to_tauri_event(event); | |
| handle.emit(name, payload).ok(); // Best-effort, don't crash on emit failure | |
| })); | |
| pub fn spawn_tauri_event_bridge(app_handle: AppHandle, event_bus: &dyn EventBus) { | |
| event_bus.subscribe(Box::new(move |event: &DomainEvent| { | |
| let (name, payload) = to_tauri_event(event); | |
| app_handle.emit(name, payload).ok(); // Best-effort, don't crash on emit failure | |
| })); | |
| } |
There was a problem hiding this comment.
1 issue found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/hooks/useTauriEvent.ts">
<violation number="1" location="src/hooks/useTauriEvent.ts:10">
P2: Attach a rejection handler to the `listen` promise when it is created. Right now `.catch()` is only added during cleanup, so if `listen()` rejects while the component is still mounted it can surface as an unhandled promise rejection.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
There was a problem hiding this comment.
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 `@src-tauri/src/adapters/driven/event/tauri_bridge.rs`:
- Around line 39-85: The event payload currently serializes u64 numeric IDs
(e.g., DomainEvent::PackageCreated { id, name } and other cases like
DownloadProgress total_bytes/totalBytes, Download* { id }, Segment* download_id)
as JSON numbers which can lose precision in JS; update each match arm that emits
a u64 to serialize it as a string (e.g., replace "id": id with "id":
id.to_string() and do the same for total_bytes/totalBytes, downloadId,
segmentId, timestamps, etc.), or alternatively switch those fields to a
string-serializing newtype so the JSON produced by event_payload uses strings
for all u64 identifiers and byte/timestamp fields consumed by the frontend.
In `@src/hooks/useTauriEvent.ts`:
- Around line 8-18: The current useEffect calls listen<T>(eventName, ...) and
only handles promise rejection during cleanup which can produce unhandled
rejections; change the logic in useEffect so you attach .catch() to the listen
promise immediately and only store or call the returned unlisten function after
the promise resolves: call listen(...).then(unlistenFn => { if (!cancelled)
save/unsubscribe via that unlistenFn; else call unlistenFn() immediately }).
Also ensure you handle errors from listen by adding a .catch(err => { /* swallow
or report error appropriately */ }) to the promise returned by listen, and keep
references to cancelled and callbackRef as currently used to avoid race
conditions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 355bb83f-efd8-407b-86c1-65a3ff6cf310
📒 Files selected for processing (7)
src-tauri/src/adapters/driven/event/mod.rssrc-tauri/src/adapters/driven/event/tauri_bridge.rssrc-tauri/src/adapters/driven/event/tokio_event_bus.rssrc-tauri/src/adapters/driven/mod.rssrc-tauri/src/lib.rssrc/hooks/useTauriEvent.test.tssrc/hooks/useTauriEvent.ts
| fn event_payload(event: &DomainEvent) -> serde_json::Value { | ||
| match event { | ||
| DomainEvent::DownloadCreated { id } | ||
| | DomainEvent::DownloadStarted { id } | ||
| | DomainEvent::DownloadPaused { id } | ||
| | DomainEvent::DownloadResumed { id } | ||
| | DomainEvent::DownloadResumedFromWait { id } | ||
| | DomainEvent::DownloadCompleted { id } | ||
| | DomainEvent::DownloadWaiting { id } | ||
| | DomainEvent::DownloadChecking { id } | ||
| | DomainEvent::DownloadExtracting { id } => json!({ "id": id.0 }), | ||
|
|
||
| DomainEvent::DownloadFailed { id, error } => json!({ "id": id.0, "error": error }), | ||
| DomainEvent::DownloadRetrying { id, attempt } => { | ||
| json!({ "id": id.0, "attempt": attempt }) | ||
| } | ||
| DomainEvent::DownloadProgress { | ||
| id, | ||
| downloaded_bytes, | ||
| total_bytes, | ||
| } => { | ||
| json!({ "id": id.0, "downloadedBytes": downloaded_bytes, "totalBytes": total_bytes }) | ||
| } | ||
|
|
||
| DomainEvent::SegmentStarted { | ||
| download_id, | ||
| segment_id, | ||
| } | ||
| | DomainEvent::SegmentCompleted { | ||
| download_id, | ||
| segment_id, | ||
| } => { | ||
| json!({ "downloadId": download_id.0, "segmentId": segment_id }) | ||
| } | ||
| DomainEvent::SegmentFailed { | ||
| download_id, | ||
| segment_id, | ||
| error, | ||
| } => { | ||
| json!({ "downloadId": download_id.0, "segmentId": segment_id, "error": error }) | ||
| } | ||
|
|
||
| DomainEvent::PluginLoaded { name, version } => { | ||
| json!({ "name": name, "version": version }) | ||
| } | ||
| DomainEvent::PluginUnloaded { name } => json!({ "name": name }), | ||
| DomainEvent::PackageCreated { id, name } => json!({ "id": id, "name": name }), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n -C2 'PackageCreated|DownloadId' src-tauri/src
rg -n -C2 'package-created|payload\.id|downloadId|segmentId|Number\.MAX_SAFE_INTEGER|bigint' srcRepository: mpiton/vortex
Length of output: 50369
🏁 Script executed:
find src -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.vue" -o -name "*.js" \) 2>/dev/null | head -20Repository: mpiton/vortex
Length of output: 250
🏁 Script executed:
rg -i "package-created|PackageCreated" src --type ts --type tsx --type vue -A 3 -B 1Repository: mpiton/vortex
Length of output: 84
🏁 Script executed:
rg "payload\.id|segmentId|downloadId" src --type ts --type tsx --type vue -A 2Repository: mpiton/vortex
Length of output: 84
🏁 Script executed:
rg -i "package.*created|packageCreated" src -A 3 -B 1Repository: mpiton/vortex
Length of output: 39
🏁 Script executed:
cat src/hooks/useTauriEvent.tsRepository: mpiton/vortex
Length of output: 637
🏁 Script executed:
rg "id.*number|id.*string|Number.MAX_SAFE_INTEGER|BigInt" src -A 2Repository: mpiton/vortex
Length of output: 39
🏁 Script executed:
find src -name "*.ts" -o -name "*.tsx" | xargs rg "listen|payload" -lRepository: mpiton/vortex
Length of output: 115
🏁 Script executed:
cat src/App.tsxRepository: mpiton/vortex
Length of output: 390
🏁 Script executed:
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec grep -l "download\|package" {} \;Repository: mpiton/vortex
Length of output: 88
🏁 Script executed:
cat src/hooks/useTauriEvent.test.tsRepository: mpiton/vortex
Length of output: 3521
🏁 Script executed:
find src -name "*.ts" -o -name "*.tsx" | xargs cat | grep -i "type\|interface" | head -30Repository: mpiton/vortex
Length of output: 473
🏁 Script executed:
git ls-files src | head -30Repository: mpiton/vortex
Length of output: 264
🏁 Script executed:
rg "PackageCreated\|package.*id\|Package {" src-tauri/src/domain -A 5 -B 1Repository: mpiton/vortex
Length of output: 196
🏁 Script executed:
rg "pub fn.*id\|generate.*id\|new.*id" src-tauri/src/domain/model/package.rs -A 2Repository: mpiton/vortex
Length of output: 39
🏁 Script executed:
cat src-tauri/src/domain/model/package.rsRepository: mpiton/vortex
Length of output: 3131
🏁 Script executed:
rg "uuid|nanoid|snowflake|id.*generate|fn.*id" src-tauri/src/domain/ports/driven -A 3Repository: mpiton/vortex
Length of output: 3370
🏁 Script executed:
rg "PackageCreated" src-tauri/src -B 2 -A 2Repository: mpiton/vortex
Length of output: 2214
🏁 Script executed:
grep -r "PackageRepository\|create.*package" src-tauri/src/domain/ports/driven --include="*.rs" -A 3Repository: mpiton/vortex
Length of output: 39
Confirm u64 IDs should be serialized as strings for JavaScript safety.
PackageCreated.id is u64 and serialized directly as a JSON number via json!({ "id": id, ... }). JavaScript will lose precision for values exceeding Number.MAX_SAFE_INTEGER (2^53−1). The frontend has no visible type narrowing or BigInt conversion to prevent this. Consider serializing the ID as a string instead:
Suggested fix
DomainEvent::PackageCreated { id, name } => json!({ "id": id.to_string(), "name": name })This applies to any other u64 fields exposed to JavaScript (e.g., DownloadMeta.total_bytes, segment byte ranges, timestamps). Alternatively, use a newtype wrapper like DownloadId to enforce string serialization at the type level.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src-tauri/src/adapters/driven/event/tauri_bridge.rs` around lines 39 - 85,
The event payload currently serializes u64 numeric IDs (e.g.,
DomainEvent::PackageCreated { id, name } and other cases like DownloadProgress
total_bytes/totalBytes, Download* { id }, Segment* download_id) as JSON numbers
which can lose precision in JS; update each match arm that emits a u64 to
serialize it as a string (e.g., replace "id": id with "id": id.to_string() and
do the same for total_bytes/totalBytes, downloadId, segmentId, timestamps,
etc.), or alternatively switch those fields to a string-serializing newtype so
the JSON produced by event_payload uses strings for all u64 identifiers and
byte/timestamp fields consumed by the frontend.
- Attach .catch() eagerly to listen() promise to prevent unhandled rejections while component is mounted (greptile, cubic, coderabbit) - Remove redundant app_handle clone in spawn_tauri_event_bridge (greptile) - Serialize PackageCreated.id as string for JS precision safety (coderabbit)
Summary
TokioEventBusadapter usingtokio::sync::broadcastchannel, with lag detection viatracing::warnfor slow subscribersEventBusand forwards all 18DomainEventvariants to the frontend as kebab-case events with camelCase JSON payloadsuseTauriEvent<T>React hook withuseRefcallback pattern, race-safe async cleanup (cancelledflag), and.catch()onunlistenpromiseArchitecture
serdein domain — serialization handled in adapter layer (tauri_bridge.rs)DomainEventvariantsTest plan
test_publish_and_receive_event— subscriber receives published event (Notify sync)test_multiple_subscribers_receive_same_event— broadcast to 2 subscriberstest_publish_no_subscriber_doesnt_block— no panic without subscriberstest_new_with_zero_capacity_uses_minimum— capacity clamped to 1test_event_name_*— all 18 variants map to correct kebab-case namestest_event_payload_*_camel_case— payloads use camelCase keysSummary by cubic
Implements a real-time event system from backend to frontend using Tauri, with a
TokioEventBus, a Tauri bridge, and auseTauriEventReact hook. Also adds safety fixes for promise handling and JS number precision.New Features
TokioEventBususingtokio::sync::broadcastwith bounded channel and lag warnings; exposed asTokioEventBusandspawn_tauri_event_bridge.DomainEventvariants as kebab-case events with camelCase JSON payloads.useTauriEvent<T>built on@tauri-apps/api/eventlisten, with ref-based callback updates and race-safe cleanup.Bug Fixes
.catch()tolisten()to prevent unhandled promise rejections.AppHandleclone in the bridge.PackageCreated.idas a string for JS precision safety.Written for commit 061e71e. Summary will update on new commits.
Summary by CodeRabbit
New Features
Tests