Skip to content

fix(websocket): deliver events to every subscriber of an instance - #181

Open
prakash-dev-code wants to merge 1 commit into
evolution-foundation:mainfrom
prakash-dev-code:fix/websocket-multi-client-delivery
Open

fix(websocket): deliver events to every subscriber of an instance#181
prakash-dev-code wants to merge 1 commit into
evolution-foundation:mainfrom
prakash-dev-code:fix/websocket-multi-client-delivery

Conversation

@prakash-dev-code

@prakash-dev-code prakash-dev-code commented Aug 26, 2026

Copy link
Copy Markdown

Description

The websocket producer stored a single connection per instance in
clients map[string]*websocket.Conn. Registering a second subscriber silently
replaced the first, and RemoveClient removed the whole instance entry when
either disconnected. Consuming events in two browser tabs delivered to only the
most recent one, and closing that tab cut delivery for the other.

This changes clients to map[string][]*client and matches deregistration to
the specific connection. Three related issues are fixed in the same path:

  1. Concurrent writes. Events are dispatched from independent goroutines
    (the go CallWebhook calls in the whatsmeow event handler) and
    gorilla/websocket permits one concurrent writer per connection, so two
    simultaneous events could interleave writes on the same socket. Each
    connection now has its own write mutex.
  2. Lock held during I/O. Produce now snapshots subscribers under the read
    lock and writes outside it, so a slow or half-open socket no longer blocks
    deliveries for other instances.
  3. Cross-producer suppression. sendToQueueOrWebhook aborts the remaining
    producers when one returns an error, so a closed browser tab previously
    suppressed RabbitMQ/NATS/webhook delivery for the same event. Failed writes
    now prune the dead connection and Produce returns nil.

No public API or behaviour change for existing single-subscriber setups.

Related Issue

N/A — found while building against the websocket producer. Happy to open an
issue first if you'd prefer that.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Testing

  • Manual testing completed
  • Functionality verified in development environment
  • No breaking changes introduced

Verified with two concurrent websocket subscribers on one instance: both
receive identical event streams, and disconnecting one leaves the other
unaffected. go build ./..., make fmt and
golangci-lint run ./pkg/events/websocket/... are all clean.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have tested my changes thoroughly
  • Any dependent changes have been merged and published

Summary by Sourcery

Ensure websocket events are delivered reliably to every active subscriber while isolating slow or disconnected connections.

Bug Fixes:

  • Deliver websocket events to all subscribers of an instance without disconnecting or replacing existing subscribers.
  • Prevent concurrent writes from corrupting deliveries and ensure failed websocket connections no longer suppress other event producers.

Enhancements:

  • Avoid holding the subscriber lock during websocket I/O and automatically remove failed connections.

The websocket producer kept a single connection per instance in
`clients map[string]*websocket.Conn`. Registering a second subscriber
silently replaced the first, and `RemoveClient` deleted the whole instance
entry when either one disconnected. Consuming events in two browser tabs
therefore delivered to only the most recent one, and closing that tab cut
delivery for the remaining subscriber too.

Changes:

- `clients` becomes `map[string][]*client`. Deregistration now matches the
  specific connection instead of removing the instance entry, so sibling
  subscribers keep receiving events.
- Each connection carries its own write mutex. Events are dispatched from
  independent goroutines (the `go CallWebhook` calls in the whatsmeow event
  handler) and gorilla/websocket permits only one concurrent writer per
  connection, so two simultaneous events could interleave writes on the
  same socket.
- `Produce` snapshots the subscriber list under the read lock and performs
  writes outside it, so a slow or half-open socket no longer blocks
  deliveries for other instances.
- Failed writes prune the dead connection, and `Produce` now returns nil.
  `sendToQueueOrWebhook` aborts the remaining producers when one returns an
  error, so a closed browser tab previously suppressed RabbitMQ, NATS and
  webhook delivery for that same event.

Verified with two concurrent subscribers on one instance: both receive
identical event streams, and disconnecting one leaves the other unaffected.
@sourcery-ai

sourcery-ai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Reviewer's Guide

The websocket producer now delivers each event to every subscriber, safely serializes concurrent writes per socket, performs network I/O outside the registry lock, and removes failed connections without propagating websocket errors that would suppress other producers.

Sequence diagram for websocket event fan-out

sequenceDiagram
    participant Producer as websocketProducer
    participant Registry as Subscriber registry
    participant ClientA as Subscriber A
    participant ClientB as Subscriber B
    participant Broadcast as Broadcast subscriber

    Producer->>Registry: RLock and snapshot subscribers
    Registry-->>Producer: instance clients and broadcast clients
    Producer->>ClientA: writeJSON(message)
    Producer->>ClientB: writeJSON(message)
    Producer->>Broadcast: writeJSON(message)
    Producer->>Registry: Lock and prune failed clients
    Producer-->>Producer: return nil
Loading

File-Level Changes

Change Details Files
Support multiple independent subscribers per instance and remove only the disconnected connection.
  • Store per-instance subscribers as slices of client wrappers.
  • Match deregistration by client identity and preserve remaining subscribers.
  • Apply the same connection-specific lifecycle handling to broadcast subscribers.
pkg/events/websocket/websocket_producer.go
Serialize writes per connection and avoid holding the subscriber lock during network I/O.
  • Add a mutex-protected writeJSON helper to each client.
  • Snapshot instance and broadcast targets under the read lock, then write outside the lock.
  • Prune connections whose writes fail without blocking unrelated producers.
pkg/events/websocket/websocket_producer.go
Prevent websocket delivery failures from suppressing other event producers.
  • Make Produce return nil after failed websocket writes.
  • Retain successful delivery and cleanup logging while allowing RabbitMQ, NATS, or webhook producers to continue.
pkg/events/websocket/websocket_producer.go

Possibly linked issues

  • #N/A: The PR adds per-connection write mutexes, directly preventing the reported concurrent write panic.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai 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.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="pkg/events/websocket/websocket_producer.go" line_range="174" />
<code_context>
+
+	var failed []*client
+	for _, c := range targets {
+		if err := c.writeJSON(message); err != nil {
+			p.loggerWrapper.GetLogger(instanceID).LogError(
+				"Erro ao enviar mensagem websocket para %s: %v", instanceID, err)
+			failed = append(failed, c)
 		}
-		p.loggerWrapper.GetLogger(instanceID).LogInfo("Mensagem websocket enviada com sucesso para instância %s na fila %s", instanceID, queueName)
 	}

-	// Envia para todos os clientes broadcast
-	for _, conn := range p.broadcast {
-		err := conn.WriteJSON(message)
-		if err != nil {
-			p.loggerWrapper.GetLogger(instanceID).LogError("Erro ao enviar mensagem broadcast websocket: %v", err)
-			continue
+	if len(failed) > 0 {
+		p.clientsMux.Lock()
+		for _, c := range failed {
+			if remaining := drop(p.clients[instanceID], c); len(remaining) == 0 {
+				delete(p.clients, instanceID)
+			} else {
+				p.clients[instanceID] = remaining
+			}
+			p.broadcast = drop(p.broadcast, c)
 		}
+		p.clientsMux.Unlock()
</code_context>
<issue_to_address>
**issue (bug_risk):** When `writeJSON` fails, the connection is removed from the producer's subscriber lists but is never closed here. Its read goroutine can remain blocked in `ReadMessage`, leaving the failed socket and goroutine allocated indefinitely when the peer does not close cleanly.

**Triggers:** When a socket becomes unusable during a write while its peer remains half-open.

**Suggested fix:** Close each failed connection after pruning it, for example by calling `_ = c.conn.Close()` so the read loop exits and performs its normal cleanup.

```suggestion
			p.broadcast = drop(p.broadcast, c)
			_ = c.conn.Close()
```
</issue_to_address>

Sourcery assessment

Approval pending. 1 finding to address first.

Blocking findings: pkg/events/websocket/websocket_producer.go:174


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

} else {
p.clients[instanceID] = remaining
}
p.broadcast = drop(p.broadcast, c)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): When writeJSON fails, the connection is removed from the producer's subscriber lists but is never closed here. Its read goroutine can remain blocked in ReadMessage, leaving the failed socket and goroutine allocated indefinitely when the peer does not close cleanly.

Triggers: When a socket becomes unusable during a write while its peer remains half-open.

Suggested fix: Close each failed connection after pruning it, for example by calling _ = c.conn.Close() so the read loop exits and performs its normal cleanup.

Suggested change
p.broadcast = drop(p.broadcast, c)
p.broadcast = drop(p.broadcast, c)
_ = c.conn.Close()

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