mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 22:13:26 +09:00
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>
This commit is contained in:
co-authored by
silverwind
wxiaoguang
Epid
parent
e15a7e9066
commit
13d0f24423
@@ -0,0 +1,190 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user