Skip to content

Comments

feat(pubsub): add reason field to UnsubcriptionEvent#590

Open
Faolain wants to merge 1 commit intodao-xyz:masterfrom
Faolain:fix/pubsub-unsubscribe-event-reason
Open

feat(pubsub): add reason field to UnsubcriptionEvent#590
Faolain wants to merge 1 commit intodao-xyz:masterfrom
Faolain:fix/pubsub-unsubscribe-event-reason

Conversation

@Faolain
Copy link
Contributor

@Faolain Faolain commented Feb 5, 2026

Why

I have an app I've been making and that my app's UnifiedP2PProvider.tsx (line 1386-1391) listens for pubsub unsubscribe events and logs them with logP2PDebug in order to understand what's going on with flakiness:

  logP2PDebug('pubsub:unsubscribe', {
      peerId,
      peerHash: detail?.from?.hashcode?.() ?? null,
      topics: Array.isArray(detail?.topics) ? detail?.topics : null,
      reason: detail?.reason ?? null,  // ← uses the patched .reason field
  })

Without the .reason field, the app couldn't distinguish between:

  • "peer-unreachable" — connection dropped (relay died, network issue)
  • "remote-unsubscribe" — peer explicitly left the topic

This was important because the app had a @peerbit/stream patch - now merged that skips peer removal when another connection is still alive. Without knowing why a peer unsubscribed, the app couldn't make correct reconnection decisions. Was the peer gone, or did they just leave one topic? The debug logging (gated by __APP_P2P_DEBUG) was critical for diagnosing these issues during development. It still not only remains crucial for debugging due to increased visibility but also general app functionality.

Summary

The UnsubcriptionEvent dispatched by DirectSub currently provides no way to distinguish why an unsubscription occurred. There are two fundamentally different unsubscription paths:

  1. Peer became unreachable — the peer disconnected or went offline (removeSubscriptions())
  2. Peer explicitly unsubscribed — the peer sent an Unsubscribe message for specific topics

Consumers of the "unsubscribe" event receive the same event shape for both cases, making it impossible to react appropriately (e.g., showing "peer went offline" vs. "peer left the topic" in a UI, or deciding whether to attempt reconnection).

Problem

Both dispatch sites create a bare UnsubcriptionEvent with no distinguishing metadata:

// removeSubscriptions() — peer went offline
this.dispatchEvent(new CustomEvent("unsubscribe", {
    detail: new UnsubcriptionEvent(publicKey, changed),
}));

// Unsubscribe message handler — peer explicitly left
this.dispatchEvent(new CustomEvent("unsubscribe", {
    detail: new UnsubcriptionEvent(sender, changed),
}));

The UnsubcriptionEvent class only has from and topics fields. There is no reason or type discriminator.

Downstream impact

I discovered this while building a browser-based P2P application using Peerbit. My connection management layer needs to distinguish between:

  • Peer unreachable: trigger reconnection logic, show "offline" indicator, preserve subscription intent
  • Remote unsubscribe: update topic membership UI, no reconnection needed, peer is still online

Without a reason field, the only workaround is to cross-reference unsubscribe events with connection state, which is racy and unreliable.

Fix

Attach a .reason string property to the UnsubcriptionEvent before dispatching:

// removeSubscriptions() path
const event = new UnsubcriptionEvent(publicKey, changed);
(event as any).reason = "peer-unreachable";
this.dispatchEvent(new CustomEvent("unsubscribe", { detail: event }));

// Unsubscribe message handler path
const event = new UnsubcriptionEvent(sender, changed);
(event as any).reason = "remote-unsubscribe";
this.dispatchEvent(new CustomEvent("unsubscribe", { detail: event }));

The as any cast is used because the UnsubcriptionEvent class in @peerbit/pubsub-interface doesn't currently declare a reason field. A cleaner upstream approach would be to add reason?: string to the UnsubcriptionEvent class definition — I kept this minimal to avoid cross-package changes, but happy to update pubsub-interface as well if preferred.

Possible values

Value Dispatch site Meaning
"peer-unreachable" removeSubscriptions() Peer disconnected / went offline
"remote-unsubscribe" Unsubscribe message handler Peer explicitly unsubscribed from topics

Testing

Included two new test cases in test/bug2-unsubscribe-reason.spec.ts:

  1. Peer disconnect (peer-unreachable): Two peers subscribe to a topic. Peer 0 stops. Verifies that the unsubscribe event on peer 1 has reason === "peer-unreachable".

    • Without fix: AssertionError: expected undefined to equal 'peer-unreachable'
    • With fix: ✔ passes
  2. Explicit unsubscribe (remote-unsubscribe): Peer 0 subscribes then explicitly calls unsubscribe(). Verifies that the unsubscribe event on peer 1 has reason === "remote-unsubscribe".

    • Without fix: AssertionError: expected undefined to equal 'remote-unsubscribe'
    • With fix: ✔ passes

Both tests use real DirectSub instances via TestSession, matching existing test patterns.

Notes

  • This is a non-breaking, additive change — existing consumers that don't check .reason are unaffected.
  • If you'd prefer the reason field to be a first-class property on UnsubcriptionEvent (in @peerbit/pubsub-interface), I'm happy to update that package as well.
  • I am happy to allow edits by maintainers on this PR.

Consumers of the "unsubscribe" event currently cannot distinguish between
a peer going offline (removeSubscriptions) and a peer explicitly
unsubscribing (Unsubscribe message). Attach a .reason string to the
event detail so callers can react appropriately.

Values: "peer-unreachable" | "remote-unsubscribe"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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