Files
Gitea/services/pubsub/redis.go
T
mohammad rahimi 13d0f24423 feat: Replace SSE with WebSocket for UI notifications (#36965)
* Closes #36942
* Fixes #19265 

Replaces the SSE-based push channel (`/user/events`) with a WebSocket
endpoint (`/-/ws`).

### What changes

- **New `/-/ws` endpoint** (authenticated). One WebSocket per origin,
shared across tabs via a single `SharedWorker`.
- **Pubsub broker** (`services/pubsub`) for fan-out by topic, behind a
`Broker` interface. `MemoryBroker` is the default (single process); a
Redis backend is available for multi-process setups, configured via
`[websocket].PUBSUB_TYPE` / `PUBSUB_CONN_STR`. The internal Gitea queue
was not usable here because it has FIFO/single-consumer semantics.
- **Push-only event production.** Events are emitted by write-triggered
notifiers — `NotificationCountChange`, `PublishStopwatchesForUser`, and
the logout publisher — wired into the existing `notify.Notifier`
interface. No server-side pollers.
- **Typed pub/sub on the client.** `web_src/js/modules/worker.ts` is a
singleton transport; features subscribe per event type via
`onUserEvent('notification-count', cb)` instead of branching on
`event.data.type`.
- **Wire contract** (`UserEventType` union) is shared between the worker
and consumers via `web_src/js/types.ts`, kept in sync with
`services/websocket/events.go`.
- **Client-side periodic polling fallback** kicks in only when the
WebSocket cannot be established (e.g. proxy blocks WS, browser lacks
module-SharedWorker support).

### What's removed

- `modules/eventsource` (SSE manager, run loop, messenger).
- `/user/events` route and `tests/integration/eventsource_test.go`.
- All server-side polling for stopwatches and notification counts.

### Stopwatch multi-tab fix

The navbar stopwatch icon was previously rendered conditionally on `{{if
$activeStopwatch}}`, so tabs loaded before the timer started had no DOM
element to update. The icon and popup are now always rendered (toggled
with `tw-hidden`), and the start/stop/cancel handlers POST silently so
all open tabs reflect the change in real time.

### Deployment note

WebSocket needs the upgrade headers to pass through a reverse proxy,
e.g. for nginx:

```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```

Without them the WebSocket cannot be established, and after 3
consecutive failed opens the shared worker signals `push-unavailable`:
the notification count and stopwatch fall back to periodic polling on
the existing `[ui.notification]` timeouts. Real-time push is lost, the
features keep working. The reverse-proxy docs need the same note (see
the `docs-update-needed` label).

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Epid <rexmrj@gmail.com>
2026-07-27 07:46:00 +00:00

191 lines
5.3 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pubsub
import (
"context"
"sync"
"time"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/nosql"
"gitea.dev/modules/util"
"github.com/redis/go-redis/v9"
)
const (
redisPingTimeout = 3 * time.Second
redisPingRetries = 10
redisPingRetryDelay = time.Second
redisPublishTimeout = 2 * time.Second
)
// RedisBroker fans out across processes via Redis pub/sub. Each topic is
// backed by a single Redis SUBSCRIBE shared between local subscribers; the
// last local Unsubscribe tears the Redis subscription down.
type RedisBroker struct {
client redis.UniversalClient
mu sync.RWMutex
topics map[string]*redisTopic
}
type redisTopic struct {
ps *redis.PubSub
subs []*redisSub
cancel context.CancelFunc
}
// redisSub pairs a delivery channel with the once that guards its close, so
// either cancel() or readLoop's error-exit path can safely close it.
type redisSub struct {
ch chan []byte
once sync.Once
}
func (s *redisSub) close() { s.once.Do(func() { close(s.ch) }) }
var _ Broker = (*RedisBroker)(nil)
func redisChannelForTopic(s string) string {
return "gitea-ws-topic:" + s
}
func NewRedisBroker(connStr string) (*RedisBroker, error) {
client := nosql.GetManager().GetRedisClient(connStr)
// context.Background not graceful.ShutdownContext: shutdown ctx may not be initialized at boot.
// Retry to ride out docker-compose start-order races (matches modules/queue).
var err error
for range redisPingRetries {
pingCtx, cancel := context.WithTimeout(context.Background(), redisPingTimeout)
err = client.Ping(pingCtx).Err()
cancel()
if err == nil {
break
}
log.Warn("pubsub redis: not ready, retrying in 1s: %v", err)
time.Sleep(redisPingRetryDelay)
}
if err != nil {
return nil, err
}
return &RedisBroker{
client: client,
topics: make(map[string]*redisTopic),
}, nil
}
func (b *RedisBroker) Subscribe(topic string) (<-chan []byte, func()) {
sub := &redisSub{ch: make(chan []byte, subChanBuffer)}
// Fast path: topic already has a Redis subscription, just attach locally.
b.mu.Lock()
if state, exists := b.topics[topic]; exists {
state.subs = append(state.subs, sub)
b.mu.Unlock()
return sub.ch, b.makeCancel(topic, sub)
}
b.mu.Unlock()
// Slow path: create the Redis subscription outside the broker mutex so
// other Subscribe/cancel calls aren't blocked on the network round-trip.
// graceful.ShutdownContext so the reader loop dies cleanly on Gitea
// shutdown even if every local subscriber has already cancelled.
// readLoop consumes the SUBSCRIBE ack; don't wait for it here, a direct
// ps.Receive blocks for its whole timeout instead of returning on the ack.
ctx, cancelCtx := context.WithCancel(graceful.GetManager().ShutdownContext())
ps := b.client.Subscribe(ctx, redisChannelForTopic(topic))
b.mu.Lock()
if existing, exists := b.topics[topic]; exists {
// Another goroutine won the create race; merge into theirs and discard ours.
existing.subs = append(existing.subs, sub)
b.mu.Unlock()
cancelCtx()
_ = ps.Close()
return sub.ch, b.makeCancel(topic, sub)
}
b.topics[topic] = &redisTopic{ps: ps, cancel: cancelCtx, subs: []*redisSub{sub}}
b.mu.Unlock()
go b.readLoop(ctx, topic, ps)
return sub.ch, b.makeCancel(topic, sub)
}
func (b *RedisBroker) makeCancel(topic string, sub *redisSub) func() {
return func() {
b.mu.Lock()
state, ok := b.topics[topic]
if !ok {
b.mu.Unlock()
sub.close()
return
}
state.subs = util.SliceRemoveAll(state.subs, sub)
if len(state.subs) == 0 {
state.cancel()
_ = state.ps.Close()
delete(b.topics, topic)
}
b.mu.Unlock()
sub.close()
}
}
func (b *RedisBroker) readLoop(ctx context.Context, topic string, ps *redis.PubSub) {
for {
msg, err := ps.ReceiveMessage(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
// Transport blip: tear the topic down so a fresh Subscribe rebuilds it.
// Closing each subscriber's channel wakes the WebSocket handler, which
// will reconnect and re-Subscribe.
b.mu.Lock()
if cur, ok := b.topics[topic]; ok && cur.ps == ps {
for _, s := range cur.subs {
s.close()
}
cur.cancel()
_ = cur.ps.Close()
delete(b.topics, topic)
}
b.mu.Unlock()
log.Trace("pubsub redis: receive on %q: %v", topic, err)
return
}
payload := []byte(msg.Payload)
b.mu.RLock()
state, ok := b.topics[topic]
if !ok {
b.mu.RUnlock()
return
}
for _, s := range state.subs {
select {
case s.ch <- payload:
default:
log.Trace("pubsub redis: dropping message on topic %q — subscriber channel full", topic)
}
}
b.mu.RUnlock()
}
}
func (b *RedisBroker) Publish(topic string, msg []byte) {
ctx, cancel := context.WithTimeout(graceful.GetManager().HammerContext(), redisPublishTimeout)
defer cancel()
if err := b.client.Publish(ctx, redisChannelForTopic(topic), msg).Err(); err != nil {
log.Error("pubsub redis: publish to %q: %v", topic, err)
}
}
// HasTopicSubscribers conservatively returns true: cross-process subscriber
// discovery via PUBSUB NUMSUB is per-node and would silently miss subscribers
// in cluster mode. Publishers do the upstream lookup unconditionally.
func (b *RedisBroker) HasTopicSubscribers(topic string) bool {
return true
}