Skip to content

feat(#182): unsubscribe + deterministic ids for per-trade subscriptions - #255

Open
codaMW wants to merge 3 commits into
MostroP2P:mainfrom
codaMW:feat/182-subscription-lifecycle
Open

feat(#182): unsubscribe + deterministic ids for per-trade subscriptions#255
codaMW wants to merge 3 commits into
MostroP2P:mainfrom
codaMW:feat/182-subscription-lifecycle

Conversation

@codaMW

@codaMW codaMW commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes the subscription-lifecycle half of #182 (the request_id half was done via #172; the chat side via #247).

Problem

subscribe_gift_wraps and subscribe_single_order (orders.rs) called client.subscribe(filter, None) with auto-generated ids and never unsubscribed, so the relay-side subscription outlived the event loop's exit (30-min idle timeout, shutdown, or completed trade). Every create/take spawned a new one they accumulated over a long session (bandwidth, duplicate notifications, relay pressure on mobile).

Fix

Mirrors the subscribe_incoming_chat pattern that #247 established:

  • Deterministic ids (trade_subscription_id / single_order_subscription_id) via subscribe_with_id, so a repeat subscribe for the same trade/order replaces in place instead of stacking. Full pubkey hex (not an 8-char prefix) to rule out collisions.
  • unsubscribe(&sub_id) at each loop's single exit point, so the relay-side subscription never outlives the task.

The limit(0) live-only replay-protection of subscribe_gift_wraps is unchanged only the subscription id changes, not the filter.

Scope

Central subscription registry deferred per the issue's own guidance (nostr-sdk re-establishes subscriptions on reconnect).

Tests

  • Unit tests for the deterministic-id logic: idempotent per pubkey, no cross-pubkey collision, expected format.
  • Unsubscribe-on-exit is covered by inspection at each single exit point, mirroring the proven subscribe_incoming_chat cleanup, since exercising it needs a live relay.
  • cargo test (213 passing), clippy -D warnings, flutter analyze all clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of order updates by preventing duplicate or stale subscriptions.
    • Ensured background watchers clean up subscriptions when they stop, time out, or shut down.
    • Stabilized repeated subscription and resubscription behavior to reduce missed updates and resource leaks.
    • Improved cleanup of pending order requests.
  • Tests

    • Added coverage for consistent subscription identification, safe watcher replacement, and pending-request cleanup.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: baf9376b-487b-47de-aa66-bca070877045

📥 Commits

Reviewing files that changed from the base of the PR and between 1397ea8 and 0d66634.

📒 Files selected for processing (1)
  • rust/src/api/orders.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • rust/src/api/orders.rs

Walkthrough

Relay watchers now use deterministic SubscriptionId values for gift-wrap and single-order subscriptions. Generation tracking prevents stale watchers from removing replacement subscriptions. Tests cover identifier generation and cleanup ownership.

Changes

Relay subscription lifecycle

Layer / File(s) Summary
Deterministic identifiers and ownership tracking
rust/src/api/orders.rs
Helper functions generate deterministic trade and order subscription IDs. Generation tracking identifies the current subscription owner. Tests verify formatting, uniqueness, generation advancement, and replacement-safe cleanup.
Gift-wrap watcher lifecycle
rust/src/api/orders.rs
subscribe_gift_wraps uses a deterministic per-trade ID. It removes pending state and unsubscribes only when it owns the current subscription generation.
Single-order watcher lifecycle
rust/src/api/orders.rs
subscribe_single_order uses a deterministic per-order ID and unsubscribes on exit only when it remains the current owner.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • MostroP2P/app issue 182 — Covers deterministic subscription IDs, explicit unsubscription, and stale-watcher protection in rust/src/api/orders.rs.

Possibly related PRs

  • MostroP2P/app#274 — Both PRs modify subscription lifecycle handling in rust/src/api/orders.rs.

Poem

A rabbit tracks each relay stream,
With stable IDs kept clean.
New watchers claim their generation,
Stale watchers leave the subscription.
Pending requests rest safe and sound.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: unsubscribe handling and deterministic IDs for per-trade subscriptions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/src/api/orders.rs`:
- Around line 3073-3097: Add an asynchronous lifecycle regression test near
trade_subscription_id_is_deterministic_and_per_pubkey and
single_order_subscription_id_matches_expected_format that starts watcher A,
replaces it with watcher B for the same trade or order, waits for A to exit, and
verifies B remains subscribed. Exercise the existing watcher replacement/cleanup
APIs and assert the active subscription state after cleanup, without changing
the subscription ID construction tests.
- Around line 1503-1509: Make deterministic subscription cleanup
generation-aware: in rust/src/api/orders.rs lines 1503-1509, update the
gift-wrap watcher cleanup to unsubscribe only when its task still owns the
current generation or serialized subscription; in lines 2379-2382, apply the
same ownership guard to single-order cleanup. Before starting each replacement
watcher, cancel and await the existing task so stale tasks cannot unsubscribe
the replacement subscription.
🪄 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 Plus

Run ID: c4129d97-788c-41fa-a319-b98472aebb60

📥 Commits

Reviewing files that changed from the base of the PR and between c0608d7 and 0989769.

📒 Files selected for processing (1)
  • rust/src/api/orders.rs

Comment thread rust/src/api/orders.rs Outdated
Comment thread rust/src/api/orders.rs
codaMW added 2 commits August 2, 2026 08:18
…bscriptions

The chat side (subscribe_incoming_chat) was already fixed via MostroP2P#247. This brings
the two remaining per-trade subscriptions in orders.rs to parity:

- subscribe_gift_wraps and subscribe_single_order called client.subscribe(_, None)
  with auto-generated ids and never unsubscribed, so the relay-side subscription
  survived the event loop's exit (30-min idle timeout, shutdown, or completed
  trade). Every create/take spawned a new one — they accumulated over a long
  session (bandwidth, duplicate notifications, relay pressure on mobile).

Fix, mirroring the subscribe_incoming_chat pattern:
- Deterministic ids (trade_subscription_id / single_order_subscription_id) via
  subscribe_with_id, so a repeat subscribe for the same trade/order replaces in
  place instead of stacking. Full pubkey hex, not an 8-char prefix, to rule out
  collisions.
- unsubscribe(&sub_id) at each loop's single exit point, so the relay-side
  subscription never outlives the task.

The limit(0) live-only semantics of subscribe_gift_wraps are unchanged — only
the subscription id changes, not the filter. Central subscription registry
deferred per the issue (nostr-sdk re-establishes subscriptions on reconnect).

Unit tests cover the deterministic-id logic (idempotent per pubkey, no collision,
expected format); the unsubscribe-on-exit is covered by inspection at the single
exit point, mirroring the proven subscribe_incoming_chat cleanup, since it needs
a live relay to exercise.
…le-task unsubscribe

CodeRabbit (Critical): because the subscription id is deterministic, a
re-subscribe for the same trade/order (e.g. a retry within the 30-min idle
window) creates watcher B under the same id, replacing A's relay subscription.
When A's task later exits, its unconditional unsubscribe would tear down B's
live subscription — the very thing that keeps B's trade responsive.

Fix: a per-id generation counter (subscription_generations, mirroring
pending_requests). Each watcher claims the id on subscribe (bumping the
generation) and, on exit, only unsubscribes if it still owns the current
generation. A superseded watcher skips cleanup and lets the newer owner keep
the subscription. Applied at both sites (subscribe_gift_wraps,
subscribe_single_order).

Guard-only, not cancel-and-await: the stale watcher self-terminates on its idle
timeout without touching the subscription, so tracking JoinHandles to cancel it
early adds machinery for no correctness gain.

Regression test asserts the ownership property directly: A then B claim the same
id, A no longer owns it (won't unsubscribe), B owns it (will) — testable as pure
logic without a live relay.
@codaMW
codaMW force-pushed the feat/182-subscription-lifecycle branch from aef6a17 to 1397ea8 Compare August 2, 2026 06:24
@codaMW

codaMW commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

@grunch Rebased onto current main (resolved parallel test-module conflicts from the dispute PRs). Heads-up on scope: CodeRabbit found a real stale-task-unsubscribe hazard that the deterministic ids introduced, so this now carries a small per-id generation guard to make cleanup ownership-aware a bit more than #182's minimal ask. If you'd rather keep it lean, the alternative is unique (auto) ids + unconditional self-unsubscribe, which fixes the leak without the guard but drops the re-subscribe dedup. Happy to go either way. Green on cargo test (237) / clippy / flutter analyze.

@ermeme ermeme Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking issue found on the current head. The stale-task unsubscribe race is guarded now, but the same stale task can still mutate shared pending-request state before the ownership check. See inline comment.

Comment thread rust/src/api/orders.rs Outdated
…rship, not just unsubscribe

ermeme review: the gift-wrap watcher's exit ran purge_pending_request
unconditionally before the owns_subscription check that guards unsubscribe. With
deterministic ids, a replacement watcher (B) can claim the same id and register
its own pending request while the superseded watcher (A) is still alive; when A
exits it correctly skips unsubscribe but still purged B's pending request,
stranding the real daemon reply so the caller fell back to NoDaemonResponse
despite an active subscription.

Move purge_pending_request inside the owns_subscription guard so a watcher that
lost ownership performs no exit-side effect on the shared id. The single-order
watcher already had no unconditional purge, so this is the only affected path.

Test: a_stale_watcher_exit_does_not_purge_a_replacements_pending_request — A
claims, B replaces and registers pending state, A exits (stale) and must not
purge, B's pending request survives; B's own exit then legitimately purges.
@codaMW

codaMW commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in 0d66634. purge_pending_request is now inside the owns_subscription(&sub_id, sub_gen) guard alongside unsubscribe, so a stale watcher that lost ownership touches neither the subscription nor the shared pending-request state the replacement watcher's pending request survives and its daemon reply can still resolve. Added a regression test (orders.rs tests) covering exactly the scenario you described: watcher A claims the id, B replaces it and registers its own pending request, A exits and having lost ownership runs no exit-side effect (the purge is skipped), and B's pending state remains intact. Owner B's own exit still purges legitimately.

@codaMW codaMW self-assigned this Aug 13, 2026
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.

1 participant