From cb3321477339a394f5c9eaecbc17d0446593e213 Mon Sep 17 00:00:00 2001 From: Epid Date: Mon, 23 Mar 2026 23:31:17 +0300 Subject: [PATCH 01/33] =?UTF-8?q?feat(websocket):=20Phase=201=20=E2=80=94?= =?UTF-8?q?=20replace=20SSE=20notification=20count=20with=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a thin in-memory pubsub broker and a SharedWorker-based WebSocket client to deliver real-time notification count updates. This replaces the SSE path for notification-count events with a persistent WebSocket connection shared across all tabs. New files: - services/pubsub/broker.go: fan-out pubsub broker (DefaultBroker singleton) - services/websocket/notifier.go: polls notification counts, publishes to broker - routers/web/websocket/websocket.go: /-/ws endpoint, per-user topic subscription - web_src/js/features/websocket.sharedworker.ts: SharedWorker with exponential backoff reconnect (50ms initial, 10s max, reconnect on close and error) Modified files: - routers/init.go: register websocket_service.Init() - routers/web/web.go: add GET /-/ws route - services/context/response.go: add Hijack() to forward http.Hijacker so coder/websocket can upgrade the connection - web_src/js/features/notification.ts: port from SSE SharedWorker to WS SharedWorker - webpack.config.ts: add websocket.sharedworker entry point Part of RFC #36942. --- routers/init.go | 2 + routers/web/web.go | 2 + routers/web/websocket/websocket.go | 53 +++++++ services/context/response.go | 11 ++ services/pubsub/broker.go | 65 ++++++++ services/websocket/notifier.go | 76 +++++++++ web_src/js/features/notification.ts | 45 ++---- web_src/js/features/websocket.sharedworker.ts | 144 ++++++++++++++++++ webpack.config.ts | 3 + 9 files changed, 369 insertions(+), 32 deletions(-) create mode 100644 routers/web/websocket/websocket.go create mode 100644 services/pubsub/broker.go create mode 100644 services/websocket/notifier.go create mode 100644 web_src/js/features/websocket.sharedworker.ts diff --git a/routers/init.go b/routers/init.go index 2ed7a57e5c..f6775dd8fe 100644 --- a/routers/init.go +++ b/routers/init.go @@ -54,6 +54,7 @@ import ( "code.gitea.io/gitea/services/task" "code.gitea.io/gitea/services/uinotification" "code.gitea.io/gitea/services/webhook" + websocket_service "code.gitea.io/gitea/services/websocket" ) func mustInit(fn func() error) { @@ -160,6 +161,7 @@ func InitWebInstalled(ctx context.Context) { mustInit(task.Init) mustInit(repo_migrations.Init) eventsource.GetManager().Init() + mustInit(websocket_service.Init) mustInitCtx(ctx, mailer_incoming.Init) mustInitCtx(ctx, syncAppConfForGit) diff --git a/routers/web/web.go b/routers/web/web.go index a76a68ed80..8aa26c1f36 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -41,6 +41,7 @@ import ( "code.gitea.io/gitea/routers/web/user" user_setting "code.gitea.io/gitea/routers/web/user/setting" "code.gitea.io/gitea/routers/web/user/setting/security" + gitea_websocket "code.gitea.io/gitea/routers/web/websocket" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -588,6 +589,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, reqSignOut) m.Any("/user/events", routing.MarkLongPolling, events.Events) + m.Get("/-/ws", gitea_websocket.Serve) m.Group("/login/oauth", func() { m.Group("", func() { diff --git a/routers/web/websocket/websocket.go b/routers/web/websocket/websocket.go new file mode 100644 index 0000000000..e0fc955cfc --- /dev/null +++ b/routers/web/websocket/websocket.go @@ -0,0 +1,53 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "encoding/json" + "fmt" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/pubsub" + + gitea_ws "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +// Serve handles WebSocket upgrade and event delivery for the signed-in user. +func Serve(ctx *context.Context) { + if !ctx.IsSigned { + ctx.Status(401) + return + } + + conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, &gitea_ws.AcceptOptions{ + InsecureSkipVerify: false, + }) + if err != nil { + log.Error("websocket: accept failed: %v", err) + return + } + defer conn.CloseNow() //nolint:errcheck + + topic := fmt.Sprintf("user-%d", ctx.Doer.ID) + ch, cancel := pubsub.DefaultBroker.Subscribe(topic) + defer cancel() + + wsCtx := ctx.Req.Context() + for { + select { + case <-wsCtx.Done(): + return + case msg, ok := <-ch: + if !ok { + return + } + if err := wsjson.Write(wsCtx, conn, json.RawMessage(msg)); err != nil { + log.Trace("websocket: write failed: %v", err) + return + } + } + } +} diff --git a/services/context/response.go b/services/context/response.go index c7368ebc6f..ac86820d70 100644 --- a/services/context/response.go +++ b/services/context/response.go @@ -4,6 +4,8 @@ package context import ( + "bufio" + "net" "net/http" web_types "code.gitea.io/gitea/modules/web/types" @@ -67,6 +69,15 @@ func (r *Response) WriteHeader(statusCode int) { } } +// Hijack implements http.Hijacker by forwarding to the underlying ResponseWriter. +// This is required for WebSocket upgrades. +func (r *Response) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := r.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, http.ErrNotSupported +} + // Flush flushes cached data func (r *Response) Flush() { if f, ok := r.ResponseWriter.(http.Flusher); ok { diff --git a/services/pubsub/broker.go b/services/pubsub/broker.go new file mode 100644 index 0000000000..c2f2dc1026 --- /dev/null +++ b/services/pubsub/broker.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pubsub + +import ( + "sync" +) + +// Broker is a simple in-memory pub/sub broker. +// It supports fan-out: one Publish call delivers the message to all active subscribers. +type Broker struct { + mu sync.RWMutex + subs map[string][]chan []byte +} + +// DefaultBroker is the global singleton used by both routers and notifiers. +var DefaultBroker = NewBroker() + +// NewBroker creates a new in-memory Broker. +func NewBroker() *Broker { + return &Broker{ + subs: make(map[string][]chan []byte), + } +} + +// Subscribe returns a channel that receives messages published to topic. +// Call the returned cancel function to unsubscribe. +func (b *Broker) Subscribe(topic string) (<-chan []byte, func()) { + ch := make(chan []byte, 8) + + b.mu.Lock() + b.subs[topic] = append(b.subs[topic], ch) + b.mu.Unlock() + + cancel := func() { + b.mu.Lock() + defer b.mu.Unlock() + subs := b.subs[topic] + for i, sub := range subs { + if sub == ch { + b.subs[topic] = append(subs[:i], subs[i+1:]...) + break + } + } + close(ch) + } + return ch, cancel +} + +// Publish sends msg to all subscribers of topic. +// Non-blocking: slow subscribers are skipped. +func (b *Broker) Publish(topic string, msg []byte) { + b.mu.RLock() + subs := b.subs[topic] + b.mu.RUnlock() + + for _, ch := range subs { + select { + case ch <- msg: + default: + // subscriber too slow — skip + } + } +} diff --git a/services/websocket/notifier.go b/services/websocket/notifier.go new file mode 100644 index 0000000000..85c98bfb7b --- /dev/null +++ b/services/websocket/notifier.go @@ -0,0 +1,76 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "context" + "fmt" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/pubsub" +) + +type notificationCountEvent struct { + Type string `json:"type"` + Count int64 `json:"count"` +} + +func userTopic(userID int64) string { + return fmt.Sprintf("user-%d", userID) +} + +// Init starts the background goroutine that polls notification counts +// and pushes updates to connected WebSocket clients. +func Init() error { + go graceful.GetManager().RunWithShutdownContext(run) + return nil +} + +func run(ctx context.Context) { + ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: WebSocket", process.SystemProcessType, true) + defer finished() + + if setting.UI.Notification.EventSourceUpdateTime <= 0 { + return + } + + then := timeutil.TimeStampNow().Add(-2) + timer := time.NewTicker(setting.UI.Notification.EventSourceUpdateTime) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + now := timeutil.TimeStampNow().Add(-2) + + uidCounts, err := activities_model.GetUIDsAndNotificationCounts(ctx, then, now) + if err != nil { + log.Error("websocket: GetUIDsAndNotificationCounts: %v", err) + continue + } + + for _, uidCount := range uidCounts { + msg, err := json.Marshal(notificationCountEvent{ + Type: "notification-count", + Count: uidCount.Count, + }) + if err != nil { + continue + } + pubsub.DefaultBroker.Publish(userTopic(uidCount.UserID), msg) + } + + then = now + } + } +} diff --git a/web_src/js/features/notification.ts b/web_src/js/features/notification.ts index 915f65f88d..e31fd5231e 100644 --- a/web_src/js/features/notification.ts +++ b/web_src/js/features/notification.ts @@ -5,12 +5,12 @@ import {logoutFromWorker} from '../modules/worker.ts'; const {appSubUrl, notificationSettings, assetVersionEncoded} = window.config; let notificationSequenceNumber = 0; -async function receiveUpdateCount(event: MessageEvent<{type: string, data: string}>) { +async function receiveUpdateCount(event: MessageEvent<{type: string, count: number}>) { try { - const data = JSON.parse(event.data.data); - for (const count of document.querySelectorAll('.notification_count')) { - count.classList.toggle('tw-hidden', data.Count === 0); - count.textContent = `${data.Count}`; + const {count} = event.data; + for (const el of document.querySelectorAll('.notification_count')) { + el.classList.toggle('tw-hidden', count === 0); + el.textContent = `${count}`; } await updateNotificationTable(); } catch (error) { @@ -21,55 +21,38 @@ async function receiveUpdateCount(event: MessageEvent<{type: string, data: strin export function initNotificationCount() { if (!document.querySelector('.notification_count')) return; - let usingPeriodicPoller = false; const startPeriodicPoller = (timeout: number, lastCount?: number) => { if (timeout <= 0 || !Number.isFinite(timeout)) return; - usingPeriodicPoller = true; lastCount = lastCount ?? getCurrentCount(); setTimeout(async () => { await updateNotificationCountWithCallback(startPeriodicPoller, timeout, lastCount); }, timeout); }; - if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) { - // Try to connect to the event source via the shared worker first - const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); + if (notificationSettings.EventSourceUpdateTime > 0 && window.SharedWorker) { + // Connect via WebSocket SharedWorker (one connection shared across all tabs) + const wsUrl = `${window.location.origin}${appSubUrl}/-/ws`.replace(/^http/, 'ws'); + const worker = new SharedWorker(`${window.__webpack_public_path__}js/websocket.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); worker.addEventListener('error', (event) => { console.error('worker error', event); }); worker.port.addEventListener('messageerror', () => { console.error('unable to deserialize message'); }); - worker.port.postMessage({ - type: 'start', - url: `${window.location.origin}${appSubUrl}/user/events`, - }); - worker.port.addEventListener('message', (event: MessageEvent<{type: string, data: string}>) => { + worker.port.postMessage({type: 'start', url: wsUrl}); + worker.port.addEventListener('message', (event: MessageEvent<{type: string, count: number, message?: string}>) => { if (!event.data || !event.data.type) { console.error('unknown worker message event', event); return; } if (event.data.type === 'notification-count') { receiveUpdateCount(event); // no await - } else if (event.data.type === 'no-event-source') { - // browser doesn't support EventSource, falling back to periodic poller - if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout); } else if (event.data.type === 'error') { console.error('worker port event error', event.data); } else if (event.data.type === 'logout') { - if (event.data.data !== 'here') { - return; - } - worker.port.postMessage({ - type: 'close', - }); + worker.port.postMessage({type: 'close'}); worker.port.close(); logoutFromWorker(); - } else if (event.data.type === 'close') { - worker.port.postMessage({ - type: 'close', - }); - worker.port.close(); } }); worker.port.addEventListener('error', (e) => { @@ -77,9 +60,7 @@ export function initNotificationCount() { }); worker.port.start(); window.addEventListener('beforeunload', () => { - worker.port.postMessage({ - type: 'close', - }); + worker.port.postMessage({type: 'close'}); worker.port.close(); }); diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts new file mode 100644 index 0000000000..88d4870f01 --- /dev/null +++ b/web_src/js/features/websocket.sharedworker.ts @@ -0,0 +1,144 @@ +// One WebSocket connection per URL, shared across all tabs via SharedWorker. +// Messages from the server are JSON objects broadcast to all connected ports. +export {}; // make this a module to avoid global scope conflicts with other sharedworker files + +const RECONNECT_DELAY_INITIAL = 50; +const RECONNECT_DELAY_MAX = 10000; + +class WsSource { + url: string; + ws: WebSocket | null; + clients: MessagePort[]; + reconnectTimer: ReturnType | null; + reconnectDelay: number; + + constructor(url: string) { + this.url = url; + this.ws = null; + this.clients = []; + this.reconnectTimer = null; + this.reconnectDelay = RECONNECT_DELAY_INITIAL; + this.connect(); + } + + connect() { + this.ws = new WebSocket(this.url); + + this.ws.addEventListener('open', () => { + this.reconnectDelay = RECONNECT_DELAY_INITIAL; + this.broadcast({type: 'status', message: `connected to ${this.url}`}); + }); + + this.ws.addEventListener('message', (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data); + this.broadcast(msg); + } catch { + // ignore malformed JSON + } + }); + + this.ws.addEventListener('close', () => { + this.ws = null; + this.scheduleReconnect(); + }); + + this.ws.addEventListener('error', () => { + this.broadcast({type: 'error', message: 'websocket error'}); + this.ws = null; + this.scheduleReconnect(); + }); + } + + scheduleReconnect() { + if (this.clients.length === 0 || this.reconnectTimer !== null) return; + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_DELAY_MAX); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, this.reconnectDelay); + } + + register(port: MessagePort) { + if (this.clients.includes(port)) return; + this.clients.push(port); + port.postMessage({type: 'status', message: `registered to ${this.url}`}); + } + + deregister(port: MessagePort): number { + const idx = this.clients.indexOf(port); + if (idx >= 0) this.clients.splice(idx, 1); + return this.clients.length; + } + + close() { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.ws?.close(); + this.ws = null; + } + + broadcast(msg: unknown) { + for (const port of this.clients) { + port.postMessage(msg); + } + } +} + +const sourcesByUrl = new Map(); +const sourcesByPort = new Map(); + +(self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => { + for (const port of e.ports) { + port.addEventListener('message', (event: MessageEvent) => { + if (event.data.type === 'start') { + const {url} = event.data; + let source = sourcesByUrl.get(url); + if (source) { + source.register(port); + sourcesByPort.set(port, source); + return; + } + source = sourcesByPort.get(port); + if (source) { + const count = source.deregister(port); + if (count === 0) { + source.close(); + sourcesByUrl.set(source.url, null); + } + } + source = new WsSource(url); + source.register(port); + sourcesByUrl.set(url, source); + sourcesByPort.set(port, source); + } else if (event.data.type === 'close') { + const source = sourcesByPort.get(port); + if (!source) return; + const count = source.deregister(port); + if (count === 0) { + source.close(); + sourcesByUrl.set(source.url, null); + sourcesByPort.set(port, null); + } + } else if (event.data.type === 'status') { + const source = sourcesByPort.get(port); + if (!source) { + port.postMessage({type: 'status', message: 'not connected'}); + return; + } + port.postMessage({ + type: 'status', + message: `url: ${source.url} readyState: ${source.ws?.readyState ?? 'null'}`, + }); + } else { + port.postMessage({ + type: 'error', + message: `received but don't know how to handle: ${JSON.stringify(event.data)}`, + }); + } + }); + port.start(); + } +}); diff --git a/webpack.config.ts b/webpack.config.ts index e3ef996909..cd601d6653 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -79,6 +79,9 @@ export default { 'eventsource.sharedworker': [ fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.ts', import.meta.url)), ], + 'websocket.sharedworker': [ + fileURLToPath(new URL('web_src/js/features/websocket.sharedworker.ts', import.meta.url)), + ], ...(!isProduction && { devtest: [ fileURLToPath(new URL('web_src/js/standalone/devtest.ts', import.meta.url)), From 607343812a88281f4d26e0f61ef1ea962c911737 Mon Sep 17 00:00:00 2001 From: Epid Date: Mon, 23 Mar 2026 23:46:17 +0300 Subject: [PATCH 02/33] chore: add github.com/coder/websocket dependency to go.mod --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 24c18d5703..9070eeee91 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/caddyserver/certmagic v0.25.1 github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20251013092601-6327009efd21 github.com/chi-middleware/proxy v1.1.1 + github.com/coder/websocket v1.8.14 github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 github.com/dlclark/regexp2 v1.11.5 github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 diff --git a/go.sum b/go.sum index 02e6532542..ccd2012c34 100644 --- a/go.sum +++ b/go.sum @@ -229,6 +229,8 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= From 3076f902eab533735bc948d235e976a728cfa99d Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 00:07:56 +0300 Subject: [PATCH 03/33] fix(websocket): use gitea modules/json, write raw bytes directly --- routers/web/websocket/websocket.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/routers/web/websocket/websocket.go b/routers/web/websocket/websocket.go index e0fc955cfc..6feb81008d 100644 --- a/routers/web/websocket/websocket.go +++ b/routers/web/websocket/websocket.go @@ -4,7 +4,6 @@ package websocket import ( - "encoding/json" "fmt" "code.gitea.io/gitea/modules/log" @@ -12,7 +11,6 @@ import ( "code.gitea.io/gitea/services/pubsub" gitea_ws "github.com/coder/websocket" - "github.com/coder/websocket/wsjson" ) // Serve handles WebSocket upgrade and event delivery for the signed-in user. @@ -29,7 +27,7 @@ func Serve(ctx *context.Context) { log.Error("websocket: accept failed: %v", err) return } - defer conn.CloseNow() //nolint:errcheck + defer conn.CloseNow() //nolint:errcheck // CloseNow is best-effort; error is intentionally ignored topic := fmt.Sprintf("user-%d", ctx.Doer.ID) ch, cancel := pubsub.DefaultBroker.Subscribe(topic) @@ -44,7 +42,7 @@ func Serve(ctx *context.Context) { if !ok { return } - if err := wsjson.Write(wsCtx, conn, json.RawMessage(msg)); err != nil { + if err := conn.Write(wsCtx, gitea_ws.MessageText, msg); err != nil { log.Trace("websocket: write failed: %v", err) return } From 096bdd0902640fd2550fa2bb4b20cacb9b6d16ca Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 01:22:03 +0300 Subject: [PATCH 04/33] fix(websocket): avoid data race with timeutil.MockUnset in tests Replace timeutil.TimeStampNow() calls in the websocket notifier with a nowTS() helper that reads time.Now().Unix() directly. TimeStampNow reads a package-level mock variable that TestIncomingEmail writes concurrently, causing a race detected by the race detector in test-pgsql CI. --- services/websocket/notifier.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/services/websocket/notifier.go b/services/websocket/notifier.go index 85c98bfb7b..af00d75d03 100644 --- a/services/websocket/notifier.go +++ b/services/websocket/notifier.go @@ -18,6 +18,12 @@ import ( "code.gitea.io/gitea/services/pubsub" ) +// nowTS returns the current time as a TimeStamp using the real wall clock, +// avoiding data races with timeutil.MockUnset during tests. +func nowTS() timeutil.TimeStamp { + return timeutil.TimeStamp(time.Now().Unix()) +} + type notificationCountEvent struct { Type string `json:"type"` Count int64 `json:"count"` @@ -42,7 +48,7 @@ func run(ctx context.Context) { return } - then := timeutil.TimeStampNow().Add(-2) + then := nowTS().Add(-2) timer := time.NewTicker(setting.UI.Notification.EventSourceUpdateTime) defer timer.Stop() @@ -51,7 +57,7 @@ func run(ctx context.Context) { case <-ctx.Done(): return case <-timer.C: - now := timeutil.TimeStampNow().Add(-2) + now := nowTS().Add(-2) uidCounts, err := activities_model.GetUIDsAndNotificationCounts(ctx, then, now) if err != nil { From 6aba649e956abcbc3924be465ea3da8496bf2863 Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 03:57:43 +0300 Subject: [PATCH 05/33] chore: run make tidy to add coder/websocket to go-licenses.json assets/go-licenses.json was missing the license entry for the newly added github.com/coder/websocket dependency. Running make tidy regenerates this file via build/generate-go-licenses.go. --- assets/go-licenses.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 30f56e5f87..cf98d95118 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -374,6 +374,11 @@ "path": "github.com/cloudflare/circl/LICENSE", "licenseText": "Copyright (c) 2019 Cloudflare. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Cloudflare nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n========================================================================\n\nCopyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, + { + "name": "github.com/coder/websocket", + "path": "github.com/coder/websocket/LICENSE.txt", + "licenseText": "Copyright (c) 2025 Coder\n\nPermission to use, copy, modify, and distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n" + }, { "name": "github.com/couchbase/go-couchbase", "path": "github.com/couchbase/go-couchbase/LICENSE", From 89e508419af5381253e3bb58e5a9c37b75d91fc5 Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 04:44:00 +0300 Subject: [PATCH 06/33] fix(websocket): address silverwind review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move /-/ws route inside reqSignIn middleware group; remove manual ctx.IsSigned check from handler (auth is now enforced by the router) - Fix scheduleReconnect() to schedule using current delay then double, so first reconnect fires after 50ms not 100ms (reported by silverwind) - Replace sourcesByPort.set(port, null) with delete() to prevent MessagePort retention after tab close (memory leak fix) - Centralize topic naming in pubsub.UserTopic() — removes duplication between the notifier and the WebSocket handler - Skip DB polling in notifier when broker has no active subscribers to avoid unnecessary load on idle instances - Hold RLock for the full Publish fan-out loop to prevent a race where cancel() closes a channel between slice read and send --- routers/web/web.go | 4 ++- routers/web/websocket/websocket.go | 11 ++------ services/pubsub/broker.go | 26 ++++++++++++++++--- services/websocket/notifier.go | 12 ++++----- web_src/js/features/websocket.sharedworker.ts | 15 ++++++----- 5 files changed, 42 insertions(+), 26 deletions(-) diff --git a/routers/web/web.go b/routers/web/web.go index 8aa26c1f36..5dad91fc0a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -589,7 +589,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, reqSignOut) m.Any("/user/events", routing.MarkLongPolling, events.Events) - m.Get("/-/ws", gitea_websocket.Serve) + m.Group("", func() { + m.Get("/-/ws", gitea_websocket.Serve) + }, reqSignIn) m.Group("/login/oauth", func() { m.Group("", func() { diff --git a/routers/web/websocket/websocket.go b/routers/web/websocket/websocket.go index 6feb81008d..cfa146e347 100644 --- a/routers/web/websocket/websocket.go +++ b/routers/web/websocket/websocket.go @@ -4,8 +4,6 @@ package websocket import ( - "fmt" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/pubsub" @@ -14,12 +12,8 @@ import ( ) // Serve handles WebSocket upgrade and event delivery for the signed-in user. +// Authentication is enforced by the reqSignIn middleware in the router. func Serve(ctx *context.Context) { - if !ctx.IsSigned { - ctx.Status(401) - return - } - conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, &gitea_ws.AcceptOptions{ InsecureSkipVerify: false, }) @@ -29,8 +23,7 @@ func Serve(ctx *context.Context) { } defer conn.CloseNow() //nolint:errcheck // CloseNow is best-effort; error is intentionally ignored - topic := fmt.Sprintf("user-%d", ctx.Doer.ID) - ch, cancel := pubsub.DefaultBroker.Subscribe(topic) + ch, cancel := pubsub.DefaultBroker.Subscribe(pubsub.UserTopic(ctx.Doer.ID)) defer cancel() wsCtx := ctx.Req.Context() diff --git a/services/pubsub/broker.go b/services/pubsub/broker.go index c2f2dc1026..9143742489 100644 --- a/services/pubsub/broker.go +++ b/services/pubsub/broker.go @@ -4,6 +4,7 @@ package pubsub import ( + "fmt" "sync" ) @@ -48,14 +49,33 @@ func (b *Broker) Subscribe(topic string) (<-chan []byte, func()) { return ch, cancel } +// UserTopic returns the pub/sub topic name for a given user ID. +// Centralised here so the notifier and the WebSocket handler always agree on the format. +func UserTopic(userID int64) string { + return fmt.Sprintf("user-%d", userID) +} + +// HasSubscribers reports whether the broker has at least one active subscriber across all topics. +func (b *Broker) HasSubscribers() bool { + b.mu.RLock() + defer b.mu.RUnlock() + for _, subs := range b.subs { + if len(subs) > 0 { + return true + } + } + return false +} + // Publish sends msg to all subscribers of topic. // Non-blocking: slow subscribers are skipped. +// The RLock is held for the entire fan-out to prevent a race where cancel() +// closes a channel between the slice read and the send. func (b *Broker) Publish(topic string, msg []byte) { b.mu.RLock() - subs := b.subs[topic] - b.mu.RUnlock() + defer b.mu.RUnlock() - for _, ch := range subs { + for _, ch := range b.subs[topic] { select { case ch <- msg: default: diff --git a/services/websocket/notifier.go b/services/websocket/notifier.go index af00d75d03..d8f64ef7d5 100644 --- a/services/websocket/notifier.go +++ b/services/websocket/notifier.go @@ -5,7 +5,6 @@ package websocket import ( "context" - "fmt" "time" activities_model "code.gitea.io/gitea/models/activities" @@ -29,10 +28,6 @@ type notificationCountEvent struct { Count int64 `json:"count"` } -func userTopic(userID int64) string { - return fmt.Sprintf("user-%d", userID) -} - // Init starts the background goroutine that polls notification counts // and pushes updates to connected WebSocket clients. func Init() error { @@ -57,6 +52,11 @@ func run(ctx context.Context) { case <-ctx.Done(): return case <-timer.C: + if !pubsub.DefaultBroker.HasSubscribers() { + then = nowTS().Add(-2) + continue + } + now := nowTS().Add(-2) uidCounts, err := activities_model.GetUIDsAndNotificationCounts(ctx, then, now) @@ -73,7 +73,7 @@ func run(ctx context.Context) { if err != nil { continue } - pubsub.DefaultBroker.Publish(userTopic(uidCount.UserID), msg) + pubsub.DefaultBroker.Publish(pubsub.UserTopic(uidCount.UserID), msg) } then = now diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts index 88d4870f01..491c7f2a07 100644 --- a/web_src/js/features/websocket.sharedworker.ts +++ b/web_src/js/features/websocket.sharedworker.ts @@ -52,11 +52,12 @@ class WsSource { scheduleReconnect() { if (this.clients.length === 0 || this.reconnectTimer !== null) return; - this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_DELAY_MAX); + const delay = this.reconnectDelay; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(); - }, this.reconnectDelay); + }, delay); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_DELAY_MAX); } register(port: MessagePort) { @@ -87,8 +88,8 @@ class WsSource { } } -const sourcesByUrl = new Map(); -const sourcesByPort = new Map(); +const sourcesByUrl = new Map(); +const sourcesByPort = new Map(); (self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => { for (const port of e.ports) { @@ -106,7 +107,7 @@ const sourcesByPort = new Map(); const count = source.deregister(port); if (count === 0) { source.close(); - sourcesByUrl.set(source.url, null); + sourcesByUrl.delete(source.url); } } source = new WsSource(url); @@ -119,8 +120,8 @@ const sourcesByPort = new Map(); const count = source.deregister(port); if (count === 0) { source.close(); - sourcesByUrl.set(source.url, null); - sourcesByPort.set(port, null); + sourcesByUrl.delete(source.url); + sourcesByPort.delete(port); } } else if (event.data.type === 'status') { const source = sourcesByPort.get(port); From 1537d8f74a8df99ac201d651df43704c6c0a0c9b Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 10:58:02 +0300 Subject: [PATCH 07/33] fix(websocket): auth via IsSigned check instead of reqSignIn middleware reqSignIn sends a 303 redirect which breaks WebSocket upgrade; use the same pattern as /user/events: register the route without middleware and return 401 inside the handler when the user is not signed in. Also fix copyright year to 2026 in all three new Go files and add a console.warn for malformed JSON in the SharedWorker. --- routers/web/web.go | 4 +--- routers/web/websocket/websocket.go | 9 +++++++-- services/pubsub/broker.go | 2 +- services/websocket/notifier.go | 2 +- web_src/js/features/websocket.sharedworker.ts | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/routers/web/web.go b/routers/web/web.go index 5dad91fc0a..8aa26c1f36 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -589,9 +589,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, reqSignOut) m.Any("/user/events", routing.MarkLongPolling, events.Events) - m.Group("", func() { - m.Get("/-/ws", gitea_websocket.Serve) - }, reqSignIn) + m.Get("/-/ws", gitea_websocket.Serve) m.Group("/login/oauth", func() { m.Group("", func() { diff --git a/routers/web/websocket/websocket.go b/routers/web/websocket/websocket.go index cfa146e347..b4d9619f6d 100644 --- a/routers/web/websocket/websocket.go +++ b/routers/web/websocket/websocket.go @@ -1,9 +1,11 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package websocket import ( + "net/http" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/pubsub" @@ -12,8 +14,11 @@ import ( ) // Serve handles WebSocket upgrade and event delivery for the signed-in user. -// Authentication is enforced by the reqSignIn middleware in the router. func Serve(ctx *context.Context) { + if !ctx.IsSigned { + ctx.Status(http.StatusUnauthorized) + return + } conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, &gitea_ws.AcceptOptions{ InsecureSkipVerify: false, }) diff --git a/services/pubsub/broker.go b/services/pubsub/broker.go index 9143742489..1a8bef5321 100644 --- a/services/pubsub/broker.go +++ b/services/pubsub/broker.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package pubsub diff --git a/services/websocket/notifier.go b/services/websocket/notifier.go index d8f64ef7d5..2d93ae49ea 100644 --- a/services/websocket/notifier.go +++ b/services/websocket/notifier.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package websocket diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts index 491c7f2a07..f8cc635570 100644 --- a/web_src/js/features/websocket.sharedworker.ts +++ b/web_src/js/features/websocket.sharedworker.ts @@ -34,7 +34,7 @@ class WsSource { const msg = JSON.parse(event.data); this.broadcast(msg); } catch { - // ignore malformed JSON + console.warn('websocket.sharedworker: received non-JSON message', event.data); } }); From f2450cc6e19eb31e722d18cfc38d1e262f8014c4 Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 13:51:16 +0300 Subject: [PATCH 08/33] fix(websocket): remove export{} from sharedworker entry point, rename worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `export {}` which caused webpack to tree-shake the entire SharedWorker bundle, resulting in an empty JS file with no connect handler — root cause of WebSocket never opening - Rename SharedWorker instance from 'notification-worker' to 'notification-worker-ws' to force browser to create a fresh worker instance instead of reusing a cached empty one --- web_src/js/features/notification.ts | 2 +- web_src/js/features/websocket.sharedworker.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/web_src/js/features/notification.ts b/web_src/js/features/notification.ts index e31fd5231e..cb025b9fd7 100644 --- a/web_src/js/features/notification.ts +++ b/web_src/js/features/notification.ts @@ -32,7 +32,7 @@ export function initNotificationCount() { if (notificationSettings.EventSourceUpdateTime > 0 && window.SharedWorker) { // Connect via WebSocket SharedWorker (one connection shared across all tabs) const wsUrl = `${window.location.origin}${appSubUrl}/-/ws`.replace(/^http/, 'ws'); - const worker = new SharedWorker(`${window.__webpack_public_path__}js/websocket.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); + const worker = new SharedWorker(`${window.__webpack_public_path__}js/websocket.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker-ws'); worker.addEventListener('error', (event) => { console.error('worker error', event); }); diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts index f8cc635570..9925ba5bd6 100644 --- a/web_src/js/features/websocket.sharedworker.ts +++ b/web_src/js/features/websocket.sharedworker.ts @@ -1,7 +1,5 @@ // One WebSocket connection per URL, shared across all tabs via SharedWorker. // Messages from the server are JSON objects broadcast to all connected ports. -export {}; // make this a module to avoid global scope conflicts with other sharedworker files - const RECONNECT_DELAY_INITIAL = 50; const RECONNECT_DELAY_MAX = 10000; From c1ba1828086a7e25056402869ecb59866c4483ef Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 22:25:26 +0300 Subject: [PATCH 09/33] fix(websocket): declare sharedworker as ES module and fix port cleanup - Add export{} to declare websocket.sharedworker.ts as an ES module, preventing TypeScript TS2451 redeclaration errors caused by global scope conflicts with eventsource.sharedworker.ts - Always delete port from sourcesByPort on close regardless of remaining subscriber count, preventing MessagePort keys from leaking in the Map --- web_src/js/features/websocket.sharedworker.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts index 9925ba5bd6..539aa46037 100644 --- a/web_src/js/features/websocket.sharedworker.ts +++ b/web_src/js/features/websocket.sharedworker.ts @@ -1,5 +1,7 @@ // One WebSocket connection per URL, shared across all tabs via SharedWorker. // Messages from the server are JSON objects broadcast to all connected ports. +export {}; // make this a module to avoid global scope conflicts with other sharedworker files + const RECONNECT_DELAY_INITIAL = 50; const RECONNECT_DELAY_MAX = 10000; @@ -116,10 +118,10 @@ const sourcesByPort = new Map(); const source = sourcesByPort.get(port); if (!source) return; const count = source.deregister(port); + sourcesByPort.delete(port); if (count === 0) { source.close(); sourcesByUrl.delete(source.url); - sourcesByPort.delete(port); } } else if (event.data.type === 'status') { const source = sourcesByPort.get(port); From b9a94c688e9f402dd5b6614e0b9291ce4f804b91 Mon Sep 17 00:00:00 2001 From: Epid Date: Mon, 23 Mar 2026 23:31:17 +0300 Subject: [PATCH 10/33] =?UTF-8?q?feat(websocket):=20Phase=201=20=E2=80=94?= =?UTF-8?q?=20replace=20SSE=20notification=20count=20with=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a thin in-memory pubsub broker and a SharedWorker-based WebSocket client to deliver real-time notification count updates. This replaces the SSE path for notification-count events with a persistent WebSocket connection shared across all tabs. New files: - services/pubsub/broker.go: fan-out pubsub broker (DefaultBroker singleton) - services/websocket/notifier.go: polls notification counts, publishes to broker - routers/web/websocket/websocket.go: /-/ws endpoint, per-user topic subscription - web_src/js/features/websocket.sharedworker.ts: SharedWorker with exponential backoff reconnect (50ms initial, 10s max, reconnect on close and error) Modified files: - routers/init.go: register websocket_service.Init() - routers/web/web.go: add GET /-/ws route - services/context/response.go: add Hijack() to forward http.Hijacker so coder/websocket can upgrade the connection - web_src/js/features/notification.ts: port from SSE SharedWorker to WS SharedWorker - webpack.config.ts: add websocket.sharedworker entry point Part of RFC #36942. --- routers/init.go | 2 + routers/web/web.go | 2 + routers/web/websocket/websocket.go | 53 +++++++ services/pubsub/broker.go | 65 ++++++++ services/websocket/notifier.go | 76 +++++++++ web_src/js/features/websocket.sharedworker.ts | 144 ++++++++++++++++++ 6 files changed, 342 insertions(+) create mode 100644 routers/web/websocket/websocket.go create mode 100644 services/pubsub/broker.go create mode 100644 services/websocket/notifier.go create mode 100644 web_src/js/features/websocket.sharedworker.ts diff --git a/routers/init.go b/routers/init.go index 2ed7a57e5c..f6775dd8fe 100644 --- a/routers/init.go +++ b/routers/init.go @@ -54,6 +54,7 @@ import ( "code.gitea.io/gitea/services/task" "code.gitea.io/gitea/services/uinotification" "code.gitea.io/gitea/services/webhook" + websocket_service "code.gitea.io/gitea/services/websocket" ) func mustInit(fn func() error) { @@ -160,6 +161,7 @@ func InitWebInstalled(ctx context.Context) { mustInit(task.Init) mustInit(repo_migrations.Init) eventsource.GetManager().Init() + mustInit(websocket_service.Init) mustInitCtx(ctx, mailer_incoming.Init) mustInitCtx(ctx, syncAppConfForGit) diff --git a/routers/web/web.go b/routers/web/web.go index 72d2c27eaf..6cf209a886 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -41,6 +41,7 @@ import ( "code.gitea.io/gitea/routers/web/user" user_setting "code.gitea.io/gitea/routers/web/user/setting" "code.gitea.io/gitea/routers/web/user/setting/security" + gitea_websocket "code.gitea.io/gitea/routers/web/websocket" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -592,6 +593,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, reqSignOut) m.Any("/user/events", routing.MarkLongPolling, events.Events) + m.Get("/-/ws", gitea_websocket.Serve) m.Group("/login/oauth", func() { m.Group("", func() { diff --git a/routers/web/websocket/websocket.go b/routers/web/websocket/websocket.go new file mode 100644 index 0000000000..e0fc955cfc --- /dev/null +++ b/routers/web/websocket/websocket.go @@ -0,0 +1,53 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "encoding/json" + "fmt" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/pubsub" + + gitea_ws "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +// Serve handles WebSocket upgrade and event delivery for the signed-in user. +func Serve(ctx *context.Context) { + if !ctx.IsSigned { + ctx.Status(401) + return + } + + conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, &gitea_ws.AcceptOptions{ + InsecureSkipVerify: false, + }) + if err != nil { + log.Error("websocket: accept failed: %v", err) + return + } + defer conn.CloseNow() //nolint:errcheck + + topic := fmt.Sprintf("user-%d", ctx.Doer.ID) + ch, cancel := pubsub.DefaultBroker.Subscribe(topic) + defer cancel() + + wsCtx := ctx.Req.Context() + for { + select { + case <-wsCtx.Done(): + return + case msg, ok := <-ch: + if !ok { + return + } + if err := wsjson.Write(wsCtx, conn, json.RawMessage(msg)); err != nil { + log.Trace("websocket: write failed: %v", err) + return + } + } + } +} diff --git a/services/pubsub/broker.go b/services/pubsub/broker.go new file mode 100644 index 0000000000..c2f2dc1026 --- /dev/null +++ b/services/pubsub/broker.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pubsub + +import ( + "sync" +) + +// Broker is a simple in-memory pub/sub broker. +// It supports fan-out: one Publish call delivers the message to all active subscribers. +type Broker struct { + mu sync.RWMutex + subs map[string][]chan []byte +} + +// DefaultBroker is the global singleton used by both routers and notifiers. +var DefaultBroker = NewBroker() + +// NewBroker creates a new in-memory Broker. +func NewBroker() *Broker { + return &Broker{ + subs: make(map[string][]chan []byte), + } +} + +// Subscribe returns a channel that receives messages published to topic. +// Call the returned cancel function to unsubscribe. +func (b *Broker) Subscribe(topic string) (<-chan []byte, func()) { + ch := make(chan []byte, 8) + + b.mu.Lock() + b.subs[topic] = append(b.subs[topic], ch) + b.mu.Unlock() + + cancel := func() { + b.mu.Lock() + defer b.mu.Unlock() + subs := b.subs[topic] + for i, sub := range subs { + if sub == ch { + b.subs[topic] = append(subs[:i], subs[i+1:]...) + break + } + } + close(ch) + } + return ch, cancel +} + +// Publish sends msg to all subscribers of topic. +// Non-blocking: slow subscribers are skipped. +func (b *Broker) Publish(topic string, msg []byte) { + b.mu.RLock() + subs := b.subs[topic] + b.mu.RUnlock() + + for _, ch := range subs { + select { + case ch <- msg: + default: + // subscriber too slow — skip + } + } +} diff --git a/services/websocket/notifier.go b/services/websocket/notifier.go new file mode 100644 index 0000000000..85c98bfb7b --- /dev/null +++ b/services/websocket/notifier.go @@ -0,0 +1,76 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "context" + "fmt" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/pubsub" +) + +type notificationCountEvent struct { + Type string `json:"type"` + Count int64 `json:"count"` +} + +func userTopic(userID int64) string { + return fmt.Sprintf("user-%d", userID) +} + +// Init starts the background goroutine that polls notification counts +// and pushes updates to connected WebSocket clients. +func Init() error { + go graceful.GetManager().RunWithShutdownContext(run) + return nil +} + +func run(ctx context.Context) { + ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: WebSocket", process.SystemProcessType, true) + defer finished() + + if setting.UI.Notification.EventSourceUpdateTime <= 0 { + return + } + + then := timeutil.TimeStampNow().Add(-2) + timer := time.NewTicker(setting.UI.Notification.EventSourceUpdateTime) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + now := timeutil.TimeStampNow().Add(-2) + + uidCounts, err := activities_model.GetUIDsAndNotificationCounts(ctx, then, now) + if err != nil { + log.Error("websocket: GetUIDsAndNotificationCounts: %v", err) + continue + } + + for _, uidCount := range uidCounts { + msg, err := json.Marshal(notificationCountEvent{ + Type: "notification-count", + Count: uidCount.Count, + }) + if err != nil { + continue + } + pubsub.DefaultBroker.Publish(userTopic(uidCount.UserID), msg) + } + + then = now + } + } +} diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts new file mode 100644 index 0000000000..88d4870f01 --- /dev/null +++ b/web_src/js/features/websocket.sharedworker.ts @@ -0,0 +1,144 @@ +// One WebSocket connection per URL, shared across all tabs via SharedWorker. +// Messages from the server are JSON objects broadcast to all connected ports. +export {}; // make this a module to avoid global scope conflicts with other sharedworker files + +const RECONNECT_DELAY_INITIAL = 50; +const RECONNECT_DELAY_MAX = 10000; + +class WsSource { + url: string; + ws: WebSocket | null; + clients: MessagePort[]; + reconnectTimer: ReturnType | null; + reconnectDelay: number; + + constructor(url: string) { + this.url = url; + this.ws = null; + this.clients = []; + this.reconnectTimer = null; + this.reconnectDelay = RECONNECT_DELAY_INITIAL; + this.connect(); + } + + connect() { + this.ws = new WebSocket(this.url); + + this.ws.addEventListener('open', () => { + this.reconnectDelay = RECONNECT_DELAY_INITIAL; + this.broadcast({type: 'status', message: `connected to ${this.url}`}); + }); + + this.ws.addEventListener('message', (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data); + this.broadcast(msg); + } catch { + // ignore malformed JSON + } + }); + + this.ws.addEventListener('close', () => { + this.ws = null; + this.scheduleReconnect(); + }); + + this.ws.addEventListener('error', () => { + this.broadcast({type: 'error', message: 'websocket error'}); + this.ws = null; + this.scheduleReconnect(); + }); + } + + scheduleReconnect() { + if (this.clients.length === 0 || this.reconnectTimer !== null) return; + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_DELAY_MAX); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, this.reconnectDelay); + } + + register(port: MessagePort) { + if (this.clients.includes(port)) return; + this.clients.push(port); + port.postMessage({type: 'status', message: `registered to ${this.url}`}); + } + + deregister(port: MessagePort): number { + const idx = this.clients.indexOf(port); + if (idx >= 0) this.clients.splice(idx, 1); + return this.clients.length; + } + + close() { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.ws?.close(); + this.ws = null; + } + + broadcast(msg: unknown) { + for (const port of this.clients) { + port.postMessage(msg); + } + } +} + +const sourcesByUrl = new Map(); +const sourcesByPort = new Map(); + +(self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => { + for (const port of e.ports) { + port.addEventListener('message', (event: MessageEvent) => { + if (event.data.type === 'start') { + const {url} = event.data; + let source = sourcesByUrl.get(url); + if (source) { + source.register(port); + sourcesByPort.set(port, source); + return; + } + source = sourcesByPort.get(port); + if (source) { + const count = source.deregister(port); + if (count === 0) { + source.close(); + sourcesByUrl.set(source.url, null); + } + } + source = new WsSource(url); + source.register(port); + sourcesByUrl.set(url, source); + sourcesByPort.set(port, source); + } else if (event.data.type === 'close') { + const source = sourcesByPort.get(port); + if (!source) return; + const count = source.deregister(port); + if (count === 0) { + source.close(); + sourcesByUrl.set(source.url, null); + sourcesByPort.set(port, null); + } + } else if (event.data.type === 'status') { + const source = sourcesByPort.get(port); + if (!source) { + port.postMessage({type: 'status', message: 'not connected'}); + return; + } + port.postMessage({ + type: 'status', + message: `url: ${source.url} readyState: ${source.ws?.readyState ?? 'null'}`, + }); + } else { + port.postMessage({ + type: 'error', + message: `received but don't know how to handle: ${JSON.stringify(event.data)}`, + }); + } + }); + port.start(); + } +}); From 69324f3e130617d37d5a922ee0260f94f7ffb491 Mon Sep 17 00:00:00 2001 From: Epid Date: Mon, 23 Mar 2026 23:46:17 +0300 Subject: [PATCH 11/33] chore: add github.com/coder/websocket dependency to go.mod --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index cebafcd0fa..9ddb3207ba 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/caddyserver/certmagic v0.25.1 github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20251013092601-6327009efd21 github.com/chi-middleware/proxy v1.1.1 + github.com/coder/websocket v1.8.14 github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 github.com/dlclark/regexp2 v1.11.5 github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 diff --git a/go.sum b/go.sum index 8c4ec50c90..1d43788e17 100644 --- a/go.sum +++ b/go.sum @@ -229,6 +229,8 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= From f26aa2f5209efb03a143e530a32132971c84df7a Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 00:07:56 +0300 Subject: [PATCH 12/33] fix(websocket): use gitea modules/json, write raw bytes directly --- routers/web/websocket/websocket.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/routers/web/websocket/websocket.go b/routers/web/websocket/websocket.go index e0fc955cfc..6feb81008d 100644 --- a/routers/web/websocket/websocket.go +++ b/routers/web/websocket/websocket.go @@ -4,7 +4,6 @@ package websocket import ( - "encoding/json" "fmt" "code.gitea.io/gitea/modules/log" @@ -12,7 +11,6 @@ import ( "code.gitea.io/gitea/services/pubsub" gitea_ws "github.com/coder/websocket" - "github.com/coder/websocket/wsjson" ) // Serve handles WebSocket upgrade and event delivery for the signed-in user. @@ -29,7 +27,7 @@ func Serve(ctx *context.Context) { log.Error("websocket: accept failed: %v", err) return } - defer conn.CloseNow() //nolint:errcheck + defer conn.CloseNow() //nolint:errcheck // CloseNow is best-effort; error is intentionally ignored topic := fmt.Sprintf("user-%d", ctx.Doer.ID) ch, cancel := pubsub.DefaultBroker.Subscribe(topic) @@ -44,7 +42,7 @@ func Serve(ctx *context.Context) { if !ok { return } - if err := wsjson.Write(wsCtx, conn, json.RawMessage(msg)); err != nil { + if err := conn.Write(wsCtx, gitea_ws.MessageText, msg); err != nil { log.Trace("websocket: write failed: %v", err) return } From 634b6383b498323f48196923a7c5c7348b276bee Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 01:22:03 +0300 Subject: [PATCH 13/33] fix(websocket): avoid data race with timeutil.MockUnset in tests Replace timeutil.TimeStampNow() calls in the websocket notifier with a nowTS() helper that reads time.Now().Unix() directly. TimeStampNow reads a package-level mock variable that TestIncomingEmail writes concurrently, causing a race detected by the race detector in test-pgsql CI. --- services/websocket/notifier.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/services/websocket/notifier.go b/services/websocket/notifier.go index 85c98bfb7b..af00d75d03 100644 --- a/services/websocket/notifier.go +++ b/services/websocket/notifier.go @@ -18,6 +18,12 @@ import ( "code.gitea.io/gitea/services/pubsub" ) +// nowTS returns the current time as a TimeStamp using the real wall clock, +// avoiding data races with timeutil.MockUnset during tests. +func nowTS() timeutil.TimeStamp { + return timeutil.TimeStamp(time.Now().Unix()) +} + type notificationCountEvent struct { Type string `json:"type"` Count int64 `json:"count"` @@ -42,7 +48,7 @@ func run(ctx context.Context) { return } - then := timeutil.TimeStampNow().Add(-2) + then := nowTS().Add(-2) timer := time.NewTicker(setting.UI.Notification.EventSourceUpdateTime) defer timer.Stop() @@ -51,7 +57,7 @@ func run(ctx context.Context) { case <-ctx.Done(): return case <-timer.C: - now := timeutil.TimeStampNow().Add(-2) + now := nowTS().Add(-2) uidCounts, err := activities_model.GetUIDsAndNotificationCounts(ctx, then, now) if err != nil { From d526aa6b660efc544d078b3268b0b72a10db3363 Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 03:57:43 +0300 Subject: [PATCH 14/33] chore: run make tidy to add coder/websocket to go-licenses.json assets/go-licenses.json was missing the license entry for the newly added github.com/coder/websocket dependency. Running make tidy regenerates this file via build/generate-go-licenses.go. --- assets/go-licenses.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 01f42f14a2..f4cfca9bdd 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -374,6 +374,11 @@ "path": "github.com/cloudflare/circl/LICENSE", "licenseText": "Copyright (c) 2019 Cloudflare. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Cloudflare nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n========================================================================\n\nCopyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, + { + "name": "github.com/coder/websocket", + "path": "github.com/coder/websocket/LICENSE.txt", + "licenseText": "Copyright (c) 2025 Coder\n\nPermission to use, copy, modify, and distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n" + }, { "name": "github.com/couchbase/go-couchbase", "path": "github.com/couchbase/go-couchbase/LICENSE", From 1a576b16c14038d333089e1030c13e66dfdc7164 Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 04:44:00 +0300 Subject: [PATCH 15/33] fix(websocket): address silverwind review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move /-/ws route inside reqSignIn middleware group; remove manual ctx.IsSigned check from handler (auth is now enforced by the router) - Fix scheduleReconnect() to schedule using current delay then double, so first reconnect fires after 50ms not 100ms (reported by silverwind) - Replace sourcesByPort.set(port, null) with delete() to prevent MessagePort retention after tab close (memory leak fix) - Centralize topic naming in pubsub.UserTopic() — removes duplication between the notifier and the WebSocket handler - Skip DB polling in notifier when broker has no active subscribers to avoid unnecessary load on idle instances - Hold RLock for the full Publish fan-out loop to prevent a race where cancel() closes a channel between slice read and send --- routers/web/web.go | 4 ++- routers/web/websocket/websocket.go | 11 ++------ services/pubsub/broker.go | 26 ++++++++++++++++--- services/websocket/notifier.go | 12 ++++----- web_src/js/features/websocket.sharedworker.ts | 15 ++++++----- 5 files changed, 42 insertions(+), 26 deletions(-) diff --git a/routers/web/web.go b/routers/web/web.go index 6cf209a886..2658f4b40d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -593,7 +593,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, reqSignOut) m.Any("/user/events", routing.MarkLongPolling, events.Events) - m.Get("/-/ws", gitea_websocket.Serve) + m.Group("", func() { + m.Get("/-/ws", gitea_websocket.Serve) + }, reqSignIn) m.Group("/login/oauth", func() { m.Group("", func() { diff --git a/routers/web/websocket/websocket.go b/routers/web/websocket/websocket.go index 6feb81008d..cfa146e347 100644 --- a/routers/web/websocket/websocket.go +++ b/routers/web/websocket/websocket.go @@ -4,8 +4,6 @@ package websocket import ( - "fmt" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/pubsub" @@ -14,12 +12,8 @@ import ( ) // Serve handles WebSocket upgrade and event delivery for the signed-in user. +// Authentication is enforced by the reqSignIn middleware in the router. func Serve(ctx *context.Context) { - if !ctx.IsSigned { - ctx.Status(401) - return - } - conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, &gitea_ws.AcceptOptions{ InsecureSkipVerify: false, }) @@ -29,8 +23,7 @@ func Serve(ctx *context.Context) { } defer conn.CloseNow() //nolint:errcheck // CloseNow is best-effort; error is intentionally ignored - topic := fmt.Sprintf("user-%d", ctx.Doer.ID) - ch, cancel := pubsub.DefaultBroker.Subscribe(topic) + ch, cancel := pubsub.DefaultBroker.Subscribe(pubsub.UserTopic(ctx.Doer.ID)) defer cancel() wsCtx := ctx.Req.Context() diff --git a/services/pubsub/broker.go b/services/pubsub/broker.go index c2f2dc1026..9143742489 100644 --- a/services/pubsub/broker.go +++ b/services/pubsub/broker.go @@ -4,6 +4,7 @@ package pubsub import ( + "fmt" "sync" ) @@ -48,14 +49,33 @@ func (b *Broker) Subscribe(topic string) (<-chan []byte, func()) { return ch, cancel } +// UserTopic returns the pub/sub topic name for a given user ID. +// Centralised here so the notifier and the WebSocket handler always agree on the format. +func UserTopic(userID int64) string { + return fmt.Sprintf("user-%d", userID) +} + +// HasSubscribers reports whether the broker has at least one active subscriber across all topics. +func (b *Broker) HasSubscribers() bool { + b.mu.RLock() + defer b.mu.RUnlock() + for _, subs := range b.subs { + if len(subs) > 0 { + return true + } + } + return false +} + // Publish sends msg to all subscribers of topic. // Non-blocking: slow subscribers are skipped. +// The RLock is held for the entire fan-out to prevent a race where cancel() +// closes a channel between the slice read and the send. func (b *Broker) Publish(topic string, msg []byte) { b.mu.RLock() - subs := b.subs[topic] - b.mu.RUnlock() + defer b.mu.RUnlock() - for _, ch := range subs { + for _, ch := range b.subs[topic] { select { case ch <- msg: default: diff --git a/services/websocket/notifier.go b/services/websocket/notifier.go index af00d75d03..d8f64ef7d5 100644 --- a/services/websocket/notifier.go +++ b/services/websocket/notifier.go @@ -5,7 +5,6 @@ package websocket import ( "context" - "fmt" "time" activities_model "code.gitea.io/gitea/models/activities" @@ -29,10 +28,6 @@ type notificationCountEvent struct { Count int64 `json:"count"` } -func userTopic(userID int64) string { - return fmt.Sprintf("user-%d", userID) -} - // Init starts the background goroutine that polls notification counts // and pushes updates to connected WebSocket clients. func Init() error { @@ -57,6 +52,11 @@ func run(ctx context.Context) { case <-ctx.Done(): return case <-timer.C: + if !pubsub.DefaultBroker.HasSubscribers() { + then = nowTS().Add(-2) + continue + } + now := nowTS().Add(-2) uidCounts, err := activities_model.GetUIDsAndNotificationCounts(ctx, then, now) @@ -73,7 +73,7 @@ func run(ctx context.Context) { if err != nil { continue } - pubsub.DefaultBroker.Publish(userTopic(uidCount.UserID), msg) + pubsub.DefaultBroker.Publish(pubsub.UserTopic(uidCount.UserID), msg) } then = now diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts index 88d4870f01..491c7f2a07 100644 --- a/web_src/js/features/websocket.sharedworker.ts +++ b/web_src/js/features/websocket.sharedworker.ts @@ -52,11 +52,12 @@ class WsSource { scheduleReconnect() { if (this.clients.length === 0 || this.reconnectTimer !== null) return; - this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_DELAY_MAX); + const delay = this.reconnectDelay; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(); - }, this.reconnectDelay); + }, delay); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_DELAY_MAX); } register(port: MessagePort) { @@ -87,8 +88,8 @@ class WsSource { } } -const sourcesByUrl = new Map(); -const sourcesByPort = new Map(); +const sourcesByUrl = new Map(); +const sourcesByPort = new Map(); (self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => { for (const port of e.ports) { @@ -106,7 +107,7 @@ const sourcesByPort = new Map(); const count = source.deregister(port); if (count === 0) { source.close(); - sourcesByUrl.set(source.url, null); + sourcesByUrl.delete(source.url); } } source = new WsSource(url); @@ -119,8 +120,8 @@ const sourcesByPort = new Map(); const count = source.deregister(port); if (count === 0) { source.close(); - sourcesByUrl.set(source.url, null); - sourcesByPort.set(port, null); + sourcesByUrl.delete(source.url); + sourcesByPort.delete(port); } } else if (event.data.type === 'status') { const source = sourcesByPort.get(port); From b47686ce321d444e35a5ae610faec1d54ad61411 Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 10:58:02 +0300 Subject: [PATCH 16/33] fix(websocket): auth via IsSigned check instead of reqSignIn middleware reqSignIn sends a 303 redirect which breaks WebSocket upgrade; use the same pattern as /user/events: register the route without middleware and return 401 inside the handler when the user is not signed in. Also fix copyright year to 2026 in all three new Go files and add a console.warn for malformed JSON in the SharedWorker. --- routers/web/web.go | 4 +--- routers/web/websocket/websocket.go | 9 +++++++-- services/pubsub/broker.go | 2 +- services/websocket/notifier.go | 2 +- web_src/js/features/websocket.sharedworker.ts | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/routers/web/web.go b/routers/web/web.go index 2658f4b40d..6cf209a886 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -593,9 +593,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, reqSignOut) m.Any("/user/events", routing.MarkLongPolling, events.Events) - m.Group("", func() { - m.Get("/-/ws", gitea_websocket.Serve) - }, reqSignIn) + m.Get("/-/ws", gitea_websocket.Serve) m.Group("/login/oauth", func() { m.Group("", func() { diff --git a/routers/web/websocket/websocket.go b/routers/web/websocket/websocket.go index cfa146e347..b4d9619f6d 100644 --- a/routers/web/websocket/websocket.go +++ b/routers/web/websocket/websocket.go @@ -1,9 +1,11 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package websocket import ( + "net/http" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/pubsub" @@ -12,8 +14,11 @@ import ( ) // Serve handles WebSocket upgrade and event delivery for the signed-in user. -// Authentication is enforced by the reqSignIn middleware in the router. func Serve(ctx *context.Context) { + if !ctx.IsSigned { + ctx.Status(http.StatusUnauthorized) + return + } conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, &gitea_ws.AcceptOptions{ InsecureSkipVerify: false, }) diff --git a/services/pubsub/broker.go b/services/pubsub/broker.go index 9143742489..1a8bef5321 100644 --- a/services/pubsub/broker.go +++ b/services/pubsub/broker.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package pubsub diff --git a/services/websocket/notifier.go b/services/websocket/notifier.go index d8f64ef7d5..2d93ae49ea 100644 --- a/services/websocket/notifier.go +++ b/services/websocket/notifier.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package websocket diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts index 491c7f2a07..f8cc635570 100644 --- a/web_src/js/features/websocket.sharedworker.ts +++ b/web_src/js/features/websocket.sharedworker.ts @@ -34,7 +34,7 @@ class WsSource { const msg = JSON.parse(event.data); this.broadcast(msg); } catch { - // ignore malformed JSON + console.warn('websocket.sharedworker: received non-JSON message', event.data); } }); From bf564d3f053e86d0f36a95a646bf7f75c1ed372e Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 13:51:16 +0300 Subject: [PATCH 17/33] fix(websocket): remove export{} from sharedworker entry point, rename worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `export {}` which caused webpack to tree-shake the entire SharedWorker bundle, resulting in an empty JS file with no connect handler — root cause of WebSocket never opening - Rename SharedWorker instance from 'notification-worker' to 'notification-worker-ws' to force browser to create a fresh worker instance instead of reusing a cached empty one --- web_src/js/features/websocket.sharedworker.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts index f8cc635570..9925ba5bd6 100644 --- a/web_src/js/features/websocket.sharedworker.ts +++ b/web_src/js/features/websocket.sharedworker.ts @@ -1,7 +1,5 @@ // One WebSocket connection per URL, shared across all tabs via SharedWorker. // Messages from the server are JSON objects broadcast to all connected ports. -export {}; // make this a module to avoid global scope conflicts with other sharedworker files - const RECONNECT_DELAY_INITIAL = 50; const RECONNECT_DELAY_MAX = 10000; From 7e8f5a89aa0042f37d9dd3aa93685848698f9b68 Mon Sep 17 00:00:00 2001 From: Epid Date: Tue, 24 Mar 2026 22:25:26 +0300 Subject: [PATCH 18/33] fix(websocket): declare sharedworker as ES module and fix port cleanup - Add export{} to declare websocket.sharedworker.ts as an ES module, preventing TypeScript TS2451 redeclaration errors caused by global scope conflicts with eventsource.sharedworker.ts - Always delete port from sourcesByPort on close regardless of remaining subscriber count, preventing MessagePort keys from leaking in the Map --- web_src/js/features/websocket.sharedworker.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts index 9925ba5bd6..539aa46037 100644 --- a/web_src/js/features/websocket.sharedworker.ts +++ b/web_src/js/features/websocket.sharedworker.ts @@ -1,5 +1,7 @@ // One WebSocket connection per URL, shared across all tabs via SharedWorker. // Messages from the server are JSON objects broadcast to all connected ports. +export {}; // make this a module to avoid global scope conflicts with other sharedworker files + const RECONNECT_DELAY_INITIAL = 50; const RECONNECT_DELAY_MAX = 10000; @@ -116,10 +118,10 @@ const sourcesByPort = new Map(); const source = sourcesByPort.get(port); if (!source) return; const count = source.deregister(port); + sourcesByPort.delete(port); if (count === 0) { source.close(); sourcesByUrl.delete(source.url); - sourcesByPort.delete(port); } } else if (event.data.type === 'status') { const source = sourcesByPort.get(port); From 2a902d1857966f70778825b3a0810974336c38df Mon Sep 17 00:00:00 2001 From: Epid Date: Mon, 30 Mar 2026 06:39:04 +0300 Subject: [PATCH 19/33] fix(websocket): add WsSource to eventsource.sharedworker, remove websocket.sharedworker - Add WsSource class to eventsource.sharedworker.ts for WebSocket transport - Remove websocket.sharedworker.ts (functionality merged into eventsource.sharedworker.ts) --- .../js/features/eventsource.sharedworker.ts | 87 +++++++++++ web_src/js/features/websocket.sharedworker.ts | 145 ------------------ 2 files changed, 87 insertions(+), 145 deletions(-) delete mode 100644 web_src/js/features/websocket.sharedworker.ts diff --git a/web_src/js/features/eventsource.sharedworker.ts b/web_src/js/features/eventsource.sharedworker.ts index 816cd7020a..58b371e6a0 100644 --- a/web_src/js/features/eventsource.sharedworker.ts +++ b/web_src/js/features/eventsource.sharedworker.ts @@ -69,8 +69,82 @@ class Source { } } +// WsSource provides a WebSocket transport alongside EventSource. +// It delivers real-time notification-count pushes using the same client list +// as the associated Source, normalising messages to the SSE event format so +// that callers do not need to know which transport delivered the event. +class WsSource { + wsUrl: string; + ws: WebSocket | null; + source: Source; + reconnectTimer: ReturnType | null; + reconnectDelay: number; + + constructor(wsUrl: string, source: Source) { + this.wsUrl = wsUrl; + this.source = source; + this.ws = null; + this.reconnectTimer = null; + this.reconnectDelay = 50; + this.connect(); + } + + connect() { + this.ws = new WebSocket(this.wsUrl); + + this.ws.addEventListener('open', () => { + this.reconnectDelay = 50; + }); + + this.ws.addEventListener('message', (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'notification-count') { + // Normalise to SSE event format so the receiver is transport-agnostic. + this.source.notifyClients({ + type: 'notification-count', + data: JSON.stringify({Count: msg.count}), + }); + } + } catch { + // ignore malformed messages + } + }); + + this.ws.addEventListener('close', () => { + this.ws = null; + this.scheduleReconnect(); + }); + + this.ws.addEventListener('error', () => { + this.ws = null; + this.scheduleReconnect(); + }); + } + + scheduleReconnect() { + if (this.reconnectTimer !== null) return; + const delay = this.reconnectDelay; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000); + } + + close() { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.ws?.close(); + this.ws = null; + } +} + const sourcesByUrl = new Map(); const sourcesByPort = new Map(); +const wsSourcesByUrl = new Map(); (self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => { for (const port of e.ports) { @@ -102,6 +176,11 @@ const sourcesByPort = new Map(); if (count === 0) { source.close(); sourcesByUrl.set(source.url, null); + const ws = wsSourcesByUrl.get(source.url); + if (ws) { + ws.close(); + wsSourcesByUrl.set(source.url, null); + } } } // Create a new Source @@ -109,6 +188,9 @@ const sourcesByPort = new Map(); source.register(port); sourcesByUrl.set(url, source); sourcesByPort.set(port, source); + // Start WebSocket alongside EventSource for real-time notification pushes. + const wsUrl = url.replace(/^http/, 'ws').replace(/\/user\/events$/, '/-/ws'); + wsSourcesByUrl.set(url, new WsSource(wsUrl, source)); } else if (event.data.type === 'listen') { const source = sourcesByPort.get(port)!; source.listen(event.data.eventType); @@ -121,6 +203,11 @@ const sourcesByPort = new Map(); source.close(); sourcesByUrl.set(source.url, null); sourcesByPort.set(port, null); + const ws = wsSourcesByUrl.get(source.url); + if (ws) { + ws.close(); + wsSourcesByUrl.set(source.url, null); + } } } else if (event.data.type === 'status') { const source = sourcesByPort.get(port); diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts deleted file mode 100644 index 539aa46037..0000000000 --- a/web_src/js/features/websocket.sharedworker.ts +++ /dev/null @@ -1,145 +0,0 @@ -// One WebSocket connection per URL, shared across all tabs via SharedWorker. -// Messages from the server are JSON objects broadcast to all connected ports. -export {}; // make this a module to avoid global scope conflicts with other sharedworker files - -const RECONNECT_DELAY_INITIAL = 50; -const RECONNECT_DELAY_MAX = 10000; - -class WsSource { - url: string; - ws: WebSocket | null; - clients: MessagePort[]; - reconnectTimer: ReturnType | null; - reconnectDelay: number; - - constructor(url: string) { - this.url = url; - this.ws = null; - this.clients = []; - this.reconnectTimer = null; - this.reconnectDelay = RECONNECT_DELAY_INITIAL; - this.connect(); - } - - connect() { - this.ws = new WebSocket(this.url); - - this.ws.addEventListener('open', () => { - this.reconnectDelay = RECONNECT_DELAY_INITIAL; - this.broadcast({type: 'status', message: `connected to ${this.url}`}); - }); - - this.ws.addEventListener('message', (event: MessageEvent) => { - try { - const msg = JSON.parse(event.data); - this.broadcast(msg); - } catch { - console.warn('websocket.sharedworker: received non-JSON message', event.data); - } - }); - - this.ws.addEventListener('close', () => { - this.ws = null; - this.scheduleReconnect(); - }); - - this.ws.addEventListener('error', () => { - this.broadcast({type: 'error', message: 'websocket error'}); - this.ws = null; - this.scheduleReconnect(); - }); - } - - scheduleReconnect() { - if (this.clients.length === 0 || this.reconnectTimer !== null) return; - const delay = this.reconnectDelay; - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.connect(); - }, delay); - this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_DELAY_MAX); - } - - register(port: MessagePort) { - if (this.clients.includes(port)) return; - this.clients.push(port); - port.postMessage({type: 'status', message: `registered to ${this.url}`}); - } - - deregister(port: MessagePort): number { - const idx = this.clients.indexOf(port); - if (idx >= 0) this.clients.splice(idx, 1); - return this.clients.length; - } - - close() { - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - this.ws?.close(); - this.ws = null; - } - - broadcast(msg: unknown) { - for (const port of this.clients) { - port.postMessage(msg); - } - } -} - -const sourcesByUrl = new Map(); -const sourcesByPort = new Map(); - -(self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => { - for (const port of e.ports) { - port.addEventListener('message', (event: MessageEvent) => { - if (event.data.type === 'start') { - const {url} = event.data; - let source = sourcesByUrl.get(url); - if (source) { - source.register(port); - sourcesByPort.set(port, source); - return; - } - source = sourcesByPort.get(port); - if (source) { - const count = source.deregister(port); - if (count === 0) { - source.close(); - sourcesByUrl.delete(source.url); - } - } - source = new WsSource(url); - source.register(port); - sourcesByUrl.set(url, source); - sourcesByPort.set(port, source); - } else if (event.data.type === 'close') { - const source = sourcesByPort.get(port); - if (!source) return; - const count = source.deregister(port); - sourcesByPort.delete(port); - if (count === 0) { - source.close(); - sourcesByUrl.delete(source.url); - } - } else if (event.data.type === 'status') { - const source = sourcesByPort.get(port); - if (!source) { - port.postMessage({type: 'status', message: 'not connected'}); - return; - } - port.postMessage({ - type: 'status', - message: `url: ${source.url} readyState: ${source.ws?.readyState ?? 'null'}`, - }); - } else { - port.postMessage({ - type: 'error', - message: `received but don't know how to handle: ${JSON.stringify(event.data)}`, - }); - } - }); - port.start(); - } -}); From 48ec6f216d7d94a2b494e09af98cc3661a86179e Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 30 Mar 2026 03:39:35 +0000 Subject: [PATCH 20/33] ci: add compliance workflow --- .gitea/workflows/compliance.yml | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .gitea/workflows/compliance.yml diff --git a/.gitea/workflows/compliance.yml b/.gitea/workflows/compliance.yml new file mode 100644 index 0000000000..341bb6d103 --- /dev/null +++ b/.gitea/workflows/compliance.yml @@ -0,0 +1,35 @@ +name: PR Compliance +on: + push: + branches: ["ci/**"] +jobs: + lint-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make deps-backend deps-tools + - name: lint-go + run: make lint-go + env: + TAGS: bindata sqlite sqlite_unlock_notify + lint-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: make deps-frontend + - name: lint-js + run: make lint-js + - name: lint-css + run: make lint-css + - name: test-frontend + run: make test-frontend From ed3bd9172c88ba9d44f52e7db6100e211dd701cb Mon Sep 17 00:00:00 2001 From: Epid Date: Mon, 30 Mar 2026 06:55:39 +0300 Subject: [PATCH 21/33] ci: expand compliance workflow to match GitHub pull-compliance.yml (13 jobs) --- .gitea/workflows/compliance.yml | 166 ++++++++++++++++++++++++++++++-- 1 file changed, 157 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/compliance.yml b/.gitea/workflows/compliance.yml index 341bb6d103..2fb39dc7ec 100644 --- a/.gitea/workflows/compliance.yml +++ b/.gitea/workflows/compliance.yml @@ -2,6 +2,7 @@ name: PR Compliance on: push: branches: ["ci/**"] + jobs: lint-backend: runs-on: ubuntu-latest @@ -12,11 +13,60 @@ jobs: go-version-file: go.mod check-latest: true - run: make deps-backend deps-tools - - name: lint-go - run: make lint-go + - run: make lint-backend env: TAGS: bindata sqlite sqlite_unlock_notify - lint-frontend: + + lint-go-windows: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make deps-backend deps-tools + - run: make lint-go-windows lint-go-gitea-vet + env: + TAGS: bindata sqlite sqlite_unlock_notify + GOOS: windows + GOARCH: amd64 + + lint-go-gogit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make deps-backend deps-tools + - run: make lint-go + env: + TAGS: bindata gogit sqlite sqlite_unlock_notify + + checks-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make deps-backend deps-tools + - run: make --always-make checks-backend + + lint-spell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make lint-spell + + frontend: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -27,9 +77,107 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - - name: lint-js - run: make lint-js - - name: lint-css - run: make lint-css - - name: test-frontend - run: make test-frontend + - run: make lint-frontend + - run: make checks-frontend + - run: make test-frontend + - run: make frontend + + lint-json: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: make deps-frontend + - run: make lint-json + + lint-swagger: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: make deps-frontend + - run: make lint-swagger + + lint-yaml: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv python install 3.12 + - run: make deps-py + - run: make lint-yaml + + lint-templates: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv python install 3.12 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: make deps-py + - run: make deps-frontend + - run: make lint-templates + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: make deps-frontend + - run: make lint-md + + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make deps-backend + - run: go build -o gitea_no_gcc + - name: build-backend-arm64 + run: make backend + env: + GOOS: linux + GOARCH: arm64 + TAGS: bindata gogit + - name: build-backend-windows + run: go build -o gitea_windows + env: + GOOS: windows + GOARCH: amd64 + TAGS: bindata gogit + - name: build-backend-386 + run: go build -o gitea_linux_386 + env: + GOOS: linux + GOARCH: 386 + + actions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make lint-actions From af2fadb96573663968bc3ea14d9818eb76737321 Mon Sep 17 00:00:00 2001 From: Epid Date: Mon, 30 Mar 2026 07:33:40 +0300 Subject: [PATCH 22/33] ci: add cleanup steps after each job to free disk space --- .gitea/workflows/compliance.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.gitea/workflows/compliance.yml b/.gitea/workflows/compliance.yml index 2fb39dc7ec..6f9d44c375 100644 --- a/.gitea/workflows/compliance.yml +++ b/.gitea/workflows/compliance.yml @@ -16,6 +16,9 @@ jobs: - run: make lint-backend env: TAGS: bindata sqlite sqlite_unlock_notify + - name: cleanup + if: always() + run: go clean -cache -testcache 2>/dev/null || true lint-go-windows: runs-on: ubuntu-latest @@ -31,6 +34,9 @@ jobs: TAGS: bindata sqlite sqlite_unlock_notify GOOS: windows GOARCH: amd64 + - name: cleanup + if: always() + run: go clean -cache -testcache 2>/dev/null || true lint-go-gogit: runs-on: ubuntu-latest @@ -44,6 +50,9 @@ jobs: - run: make lint-go env: TAGS: bindata gogit sqlite sqlite_unlock_notify + - name: cleanup + if: always() + run: go clean -cache -testcache 2>/dev/null || true checks-backend: runs-on: ubuntu-latest @@ -55,6 +64,9 @@ jobs: check-latest: true - run: make deps-backend deps-tools - run: make --always-make checks-backend + - name: cleanup + if: always() + run: go clean -cache -testcache 2>/dev/null || true lint-spell: runs-on: ubuntu-latest @@ -65,6 +77,9 @@ jobs: go-version-file: go.mod check-latest: true - run: make lint-spell + - name: cleanup + if: always() + run: go clean -cache -testcache 2>/dev/null || true frontend: runs-on: ubuntu-latest @@ -81,6 +96,9 @@ jobs: - run: make checks-frontend - run: make test-frontend - run: make frontend + - name: cleanup + if: always() + run: rm -rf dist/ node_modules/.cache 2>/dev/null || true lint-json: runs-on: ubuntu-latest @@ -154,23 +172,32 @@ jobs: check-latest: true - run: make deps-backend - run: go build -o gitea_no_gcc + - name: cleanup-before-cross-compile + run: go clean -cache && rm -f gitea_no_gcc - name: build-backend-arm64 run: make backend env: GOOS: linux GOARCH: arm64 TAGS: bindata gogit + - name: cleanup-arm64 + run: go clean -cache && rm -f gitea - name: build-backend-windows run: go build -o gitea_windows env: GOOS: windows GOARCH: amd64 TAGS: bindata gogit + - name: cleanup-windows + run: go clean -cache && rm -f gitea_windows - name: build-backend-386 run: go build -o gitea_linux_386 env: GOOS: linux GOARCH: 386 + - name: cleanup + if: always() + run: go clean -cache -testcache && rm -f gitea_linux_386 2>/dev/null || true actions: runs-on: ubuntu-latest @@ -181,3 +208,6 @@ jobs: go-version-file: go.mod check-latest: true - run: make lint-actions + - name: cleanup + if: always() + run: go clean -cache -testcache 2>/dev/null || true From 36b28c7e04c683afbe4688ae597a80a8578ce5b0 Mon Sep 17 00:00:00 2001 From: Epid Date: Mon, 30 Mar 2026 06:39:04 +0300 Subject: [PATCH 25/33] fix(websocket): add WsSource to eventsource.sharedworker, remove websocket.sharedworker - Add WsSource class to eventsource.sharedworker.ts for WebSocket transport - Remove websocket.sharedworker.ts (functionality merged into eventsource.sharedworker.ts) --- .../js/features/eventsource.sharedworker.ts | 87 +++++++++++ web_src/js/features/websocket.sharedworker.ts | 145 ------------------ 2 files changed, 87 insertions(+), 145 deletions(-) delete mode 100644 web_src/js/features/websocket.sharedworker.ts diff --git a/web_src/js/features/eventsource.sharedworker.ts b/web_src/js/features/eventsource.sharedworker.ts index 816cd7020a..58b371e6a0 100644 --- a/web_src/js/features/eventsource.sharedworker.ts +++ b/web_src/js/features/eventsource.sharedworker.ts @@ -69,8 +69,82 @@ class Source { } } +// WsSource provides a WebSocket transport alongside EventSource. +// It delivers real-time notification-count pushes using the same client list +// as the associated Source, normalising messages to the SSE event format so +// that callers do not need to know which transport delivered the event. +class WsSource { + wsUrl: string; + ws: WebSocket | null; + source: Source; + reconnectTimer: ReturnType | null; + reconnectDelay: number; + + constructor(wsUrl: string, source: Source) { + this.wsUrl = wsUrl; + this.source = source; + this.ws = null; + this.reconnectTimer = null; + this.reconnectDelay = 50; + this.connect(); + } + + connect() { + this.ws = new WebSocket(this.wsUrl); + + this.ws.addEventListener('open', () => { + this.reconnectDelay = 50; + }); + + this.ws.addEventListener('message', (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'notification-count') { + // Normalise to SSE event format so the receiver is transport-agnostic. + this.source.notifyClients({ + type: 'notification-count', + data: JSON.stringify({Count: msg.count}), + }); + } + } catch { + // ignore malformed messages + } + }); + + this.ws.addEventListener('close', () => { + this.ws = null; + this.scheduleReconnect(); + }); + + this.ws.addEventListener('error', () => { + this.ws = null; + this.scheduleReconnect(); + }); + } + + scheduleReconnect() { + if (this.reconnectTimer !== null) return; + const delay = this.reconnectDelay; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000); + } + + close() { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.ws?.close(); + this.ws = null; + } +} + const sourcesByUrl = new Map(); const sourcesByPort = new Map(); +const wsSourcesByUrl = new Map(); (self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => { for (const port of e.ports) { @@ -102,6 +176,11 @@ const sourcesByPort = new Map(); if (count === 0) { source.close(); sourcesByUrl.set(source.url, null); + const ws = wsSourcesByUrl.get(source.url); + if (ws) { + ws.close(); + wsSourcesByUrl.set(source.url, null); + } } } // Create a new Source @@ -109,6 +188,9 @@ const sourcesByPort = new Map(); source.register(port); sourcesByUrl.set(url, source); sourcesByPort.set(port, source); + // Start WebSocket alongside EventSource for real-time notification pushes. + const wsUrl = url.replace(/^http/, 'ws').replace(/\/user\/events$/, '/-/ws'); + wsSourcesByUrl.set(url, new WsSource(wsUrl, source)); } else if (event.data.type === 'listen') { const source = sourcesByPort.get(port)!; source.listen(event.data.eventType); @@ -121,6 +203,11 @@ const sourcesByPort = new Map(); source.close(); sourcesByUrl.set(source.url, null); sourcesByPort.set(port, null); + const ws = wsSourcesByUrl.get(source.url); + if (ws) { + ws.close(); + wsSourcesByUrl.set(source.url, null); + } } } else if (event.data.type === 'status') { const source = sourcesByPort.get(port); diff --git a/web_src/js/features/websocket.sharedworker.ts b/web_src/js/features/websocket.sharedworker.ts deleted file mode 100644 index 539aa46037..0000000000 --- a/web_src/js/features/websocket.sharedworker.ts +++ /dev/null @@ -1,145 +0,0 @@ -// One WebSocket connection per URL, shared across all tabs via SharedWorker. -// Messages from the server are JSON objects broadcast to all connected ports. -export {}; // make this a module to avoid global scope conflicts with other sharedworker files - -const RECONNECT_DELAY_INITIAL = 50; -const RECONNECT_DELAY_MAX = 10000; - -class WsSource { - url: string; - ws: WebSocket | null; - clients: MessagePort[]; - reconnectTimer: ReturnType | null; - reconnectDelay: number; - - constructor(url: string) { - this.url = url; - this.ws = null; - this.clients = []; - this.reconnectTimer = null; - this.reconnectDelay = RECONNECT_DELAY_INITIAL; - this.connect(); - } - - connect() { - this.ws = new WebSocket(this.url); - - this.ws.addEventListener('open', () => { - this.reconnectDelay = RECONNECT_DELAY_INITIAL; - this.broadcast({type: 'status', message: `connected to ${this.url}`}); - }); - - this.ws.addEventListener('message', (event: MessageEvent) => { - try { - const msg = JSON.parse(event.data); - this.broadcast(msg); - } catch { - console.warn('websocket.sharedworker: received non-JSON message', event.data); - } - }); - - this.ws.addEventListener('close', () => { - this.ws = null; - this.scheduleReconnect(); - }); - - this.ws.addEventListener('error', () => { - this.broadcast({type: 'error', message: 'websocket error'}); - this.ws = null; - this.scheduleReconnect(); - }); - } - - scheduleReconnect() { - if (this.clients.length === 0 || this.reconnectTimer !== null) return; - const delay = this.reconnectDelay; - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.connect(); - }, delay); - this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_DELAY_MAX); - } - - register(port: MessagePort) { - if (this.clients.includes(port)) return; - this.clients.push(port); - port.postMessage({type: 'status', message: `registered to ${this.url}`}); - } - - deregister(port: MessagePort): number { - const idx = this.clients.indexOf(port); - if (idx >= 0) this.clients.splice(idx, 1); - return this.clients.length; - } - - close() { - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - this.ws?.close(); - this.ws = null; - } - - broadcast(msg: unknown) { - for (const port of this.clients) { - port.postMessage(msg); - } - } -} - -const sourcesByUrl = new Map(); -const sourcesByPort = new Map(); - -(self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => { - for (const port of e.ports) { - port.addEventListener('message', (event: MessageEvent) => { - if (event.data.type === 'start') { - const {url} = event.data; - let source = sourcesByUrl.get(url); - if (source) { - source.register(port); - sourcesByPort.set(port, source); - return; - } - source = sourcesByPort.get(port); - if (source) { - const count = source.deregister(port); - if (count === 0) { - source.close(); - sourcesByUrl.delete(source.url); - } - } - source = new WsSource(url); - source.register(port); - sourcesByUrl.set(url, source); - sourcesByPort.set(port, source); - } else if (event.data.type === 'close') { - const source = sourcesByPort.get(port); - if (!source) return; - const count = source.deregister(port); - sourcesByPort.delete(port); - if (count === 0) { - source.close(); - sourcesByUrl.delete(source.url); - } - } else if (event.data.type === 'status') { - const source = sourcesByPort.get(port); - if (!source) { - port.postMessage({type: 'status', message: 'not connected'}); - return; - } - port.postMessage({ - type: 'status', - message: `url: ${source.url} readyState: ${source.ws?.readyState ?? 'null'}`, - }); - } else { - port.postMessage({ - type: 'error', - message: `received but don't know how to handle: ${JSON.stringify(event.data)}`, - }); - } - }); - port.start(); - } -}); From 256aeb9dc9594e22bbc0479b6f16559dc7f79fee Mon Sep 17 00:00:00 2001 From: Epid Date: Thu, 2 Apr 2026 00:41:09 +0300 Subject: [PATCH 26/33] =?UTF-8?q?feat(websocket):=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20migrate=20stopwatches/logout=20to=20WebSocket,=20remove=20SS?= =?UTF-8?q?E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stopwatch_notifier.go: periodic poller publishes stopwatches via pubsub broker - Add logout_publisher.go: PublishLogout publishes logout events via pubsub broker - websocket.go: rewrite logout messages server-side (sessionID to here/elsewhere) - Remove entire SSE infrastructure: eventsource module, /user/events route, events.go - Update blockexpensive/qos to handle /-/ws instead of /user/events - Simplify eventsource.sharedworker.ts: remove EventSource, WebSocket-only delivery --- modules/eventsource/event.go | 118 ----------------- modules/eventsource/event_test.go | 50 ------- modules/eventsource/manager.go | 89 ------------- modules/eventsource/manager_run.go | 122 ------------------ modules/eventsource/messenger.go | 77 ----------- routers/common/blockexpensive.go | 2 +- routers/common/blockexpensive_test.go | 2 +- routers/common/qos.go | 4 +- routers/init.go | 3 +- routers/web/auth/auth.go | 7 +- routers/web/events/events.go | 122 ------------------ routers/web/repo/issue_stopwatch.go | 7 +- routers/web/web.go | 2 - routers/web/websocket/websocket.go | 35 +++++ services/user/user.go | 6 +- services/websocket/logout_publisher.go | 30 +++++ services/websocket/stopwatch_notifier.go | 108 ++++++++++++++++ tests/integration/eventsource_test.go | 86 ------------ .../js/features/eventsource.sharedworker.ts | 67 +++------- 19 files changed, 203 insertions(+), 734 deletions(-) delete mode 100644 modules/eventsource/event.go delete mode 100644 modules/eventsource/event_test.go delete mode 100644 modules/eventsource/manager.go delete mode 100644 modules/eventsource/manager_run.go delete mode 100644 modules/eventsource/messenger.go delete mode 100644 routers/web/events/events.go create mode 100644 services/websocket/logout_publisher.go create mode 100644 services/websocket/stopwatch_notifier.go delete mode 100644 tests/integration/eventsource_test.go diff --git a/modules/eventsource/event.go b/modules/eventsource/event.go deleted file mode 100644 index ebcca50903..0000000000 --- a/modules/eventsource/event.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package eventsource - -import ( - "bytes" - "fmt" - "io" - "strings" - "time" - - "code.gitea.io/gitea/modules/json" -) - -func wrapNewlines(w io.Writer, prefix, value []byte) (sum int64, err error) { - if len(value) == 0 { - return 0, nil - } - var n int - last := 0 - for j := bytes.IndexByte(value, '\n'); j > -1; j = bytes.IndexByte(value[last:], '\n') { - n, err = w.Write(prefix) - sum += int64(n) - if err != nil { - return sum, err - } - n, err = w.Write(value[last : last+j+1]) - sum += int64(n) - if err != nil { - return sum, err - } - last += j + 1 - } - n, err = w.Write(prefix) - sum += int64(n) - if err != nil { - return sum, err - } - n, err = w.Write(value[last:]) - sum += int64(n) - if err != nil { - return sum, err - } - n, err = w.Write([]byte("\n")) - sum += int64(n) - return sum, err -} - -// Event is an eventsource event, not all fields need to be set -type Event struct { - // Name represents the value of the event: tag in the stream - Name string - // Data is either JSONified []byte or any that can be JSONd - Data any - // ID represents the ID of an event - ID string - // Retry tells the receiver only to attempt to reconnect to the source after this time - Retry time.Duration -} - -// WriteTo writes data to w until there's no more data to write or when an error occurs. -// The return value n is the number of bytes written. Any error encountered during the write is also returned. -func (e *Event) WriteTo(w io.Writer) (int64, error) { - sum := int64(0) - var nint int - n, err := wrapNewlines(w, []byte("event: "), []byte(e.Name)) - sum += n - if err != nil { - return sum, err - } - - if e.Data != nil { - var data []byte - switch v := e.Data.(type) { - case []byte: - data = v - case string: - data = []byte(v) - default: - var err error - data, err = json.Marshal(e.Data) - if err != nil { - return sum, err - } - } - n, err := wrapNewlines(w, []byte("data: "), data) - sum += n - if err != nil { - return sum, err - } - } - - n, err = wrapNewlines(w, []byte("id: "), []byte(e.ID)) - sum += n - if err != nil { - return sum, err - } - - if e.Retry != 0 { - nint, err = fmt.Fprintf(w, "retry: %d\n", int64(e.Retry/time.Millisecond)) - sum += int64(nint) - if err != nil { - return sum, err - } - } - - nint, err = w.Write([]byte("\n")) - sum += int64(nint) - - return sum, err -} - -func (e *Event) String() string { - buf := new(strings.Builder) - _, _ = e.WriteTo(buf) - return buf.String() -} diff --git a/modules/eventsource/event_test.go b/modules/eventsource/event_test.go deleted file mode 100644 index a1c3e5c7a8..0000000000 --- a/modules/eventsource/event_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package eventsource - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_wrapNewlines(t *testing.T) { - tests := []struct { - name string - prefix string - value string - output string - }{ - { - "check no new lines", - "prefix: ", - "value", - "prefix: value\n", - }, - { - "check simple newline", - "prefix: ", - "value1\nvalue2", - "prefix: value1\nprefix: value2\n", - }, - { - "check pathological newlines", - "p: ", - "\n1\n\n2\n3\n", - "p: \np: 1\np: \np: 2\np: 3\np: \n", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := &bytes.Buffer{} - gotSum, err := wrapNewlines(w, []byte(tt.prefix), []byte(tt.value)) - require.NoError(t, err) - - assert.EqualValues(t, len(tt.output), gotSum) - assert.Equal(t, tt.output, w.String()) - }) - } -} diff --git a/modules/eventsource/manager.go b/modules/eventsource/manager.go deleted file mode 100644 index 7ed2a82903..0000000000 --- a/modules/eventsource/manager.go +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package eventsource - -import ( - "sync" -) - -// Manager manages the eventsource Messengers -type Manager struct { - mutex sync.Mutex - - messengers map[int64]*Messenger - connection chan struct{} -} - -var manager *Manager - -func init() { - manager = &Manager{ - messengers: make(map[int64]*Messenger), - connection: make(chan struct{}, 1), - } -} - -// GetManager returns a Manager and initializes one as singleton if there's none yet -func GetManager() *Manager { - return manager -} - -// Register message channel -func (m *Manager) Register(uid int64) <-chan *Event { - m.mutex.Lock() - messenger, ok := m.messengers[uid] - if !ok { - messenger = NewMessenger(uid) - m.messengers[uid] = messenger - } - select { - case m.connection <- struct{}{}: - default: - } - m.mutex.Unlock() - return messenger.Register() -} - -// Unregister message channel -func (m *Manager) Unregister(uid int64, channel <-chan *Event) { - m.mutex.Lock() - defer m.mutex.Unlock() - messenger, ok := m.messengers[uid] - if !ok { - return - } - if messenger.Unregister(channel) { - delete(m.messengers, uid) - } -} - -// UnregisterAll message channels -func (m *Manager) UnregisterAll() { - m.mutex.Lock() - defer m.mutex.Unlock() - for _, messenger := range m.messengers { - messenger.UnregisterAll() - } - m.messengers = map[int64]*Messenger{} -} - -// SendMessage sends a message to a particular user -func (m *Manager) SendMessage(uid int64, message *Event) { - m.mutex.Lock() - messenger, ok := m.messengers[uid] - m.mutex.Unlock() - if ok { - messenger.SendMessage(message) - } -} - -// SendMessageBlocking sends a message to a particular user -func (m *Manager) SendMessageBlocking(uid int64, message *Event) { - m.mutex.Lock() - messenger, ok := m.messengers[uid] - m.mutex.Unlock() - if ok { - messenger.SendMessageBlocking(message) - } -} diff --git a/modules/eventsource/manager_run.go b/modules/eventsource/manager_run.go deleted file mode 100644 index 4a42224dda..0000000000 --- a/modules/eventsource/manager_run.go +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package eventsource - -import ( - "context" - "time" - - activities_model "code.gitea.io/gitea/models/activities" - issues_model "code.gitea.io/gitea/models/issues" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/graceful" - "code.gitea.io/gitea/modules/json" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/process" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/services/convert" -) - -// Init starts this eventsource -func (m *Manager) Init() { - if setting.UI.Notification.EventSourceUpdateTime <= 0 { - return - } - go graceful.GetManager().RunWithShutdownContext(m.Run) -} - -// Run runs the manager within a provided context -func (m *Manager) Run(ctx context.Context) { - ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: EventSource", process.SystemProcessType, true) - defer finished() - - then := timeutil.TimeStampNow().Add(-2) - timer := time.NewTicker(setting.UI.Notification.EventSourceUpdateTime) -loop: - for { - select { - case <-ctx.Done(): - timer.Stop() - break loop - case <-timer.C: - m.mutex.Lock() - connectionCount := len(m.messengers) - if connectionCount == 0 { - log.Trace("Event source has no listeners") - // empty the connection channel - select { - case <-m.connection: - default: - } - } - m.mutex.Unlock() - if connectionCount == 0 { - // No listeners so the source can be paused - log.Trace("Pausing the eventsource") - select { - case <-ctx.Done(): - break loop - case <-m.connection: - log.Trace("Connection detected - restarting the eventsource") - // OK we're back so lets reset the timer and start again - // We won't change the "then" time because there could be concurrency issues - select { - case <-timer.C: - default: - } - continue - } - } - - now := timeutil.TimeStampNow().Add(-2) - - uidCounts, err := activities_model.GetUIDsAndNotificationCounts(ctx, then, now) - if err != nil { - log.Error("Unable to get UIDcounts: %v", err) - } - for _, uidCount := range uidCounts { - m.SendMessage(uidCount.UserID, &Event{ - Name: "notification-count", - Data: uidCount, - }) - } - then = now - - if setting.Service.EnableTimetracking { - usersStopwatches, err := issues_model.GetUIDsAndStopwatch(ctx) - if err != nil { - log.Error("Unable to get GetUIDsAndStopwatch: %v", err) - return - } - - for _, userStopwatches := range usersStopwatches { - u, err := user_model.GetUserByID(ctx, userStopwatches.UserID) - if err != nil { - log.Error("Unable to get user %d: %v", userStopwatches.UserID, err) - continue - } - - apiSWs, err := convert.ToStopWatches(ctx, u, userStopwatches.StopWatches) - if err != nil { - if !issues_model.IsErrIssueNotExist(err) { - log.Error("Unable to APIFormat stopwatches: %v", err) - } - continue - } - dataBs, err := json.Marshal(apiSWs) - if err != nil { - log.Error("Unable to marshal stopwatches: %v", err) - continue - } - m.SendMessage(userStopwatches.UserID, &Event{ - Name: "stopwatches", - Data: string(dataBs), - }) - } - } - } - } - m.UnregisterAll() -} diff --git a/modules/eventsource/messenger.go b/modules/eventsource/messenger.go deleted file mode 100644 index 6df26716be..0000000000 --- a/modules/eventsource/messenger.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package eventsource - -import "sync" - -// Messenger is a per uid message store -type Messenger struct { - mutex sync.Mutex - uid int64 - channels []chan *Event -} - -// NewMessenger creates a messenger for a particular uid -func NewMessenger(uid int64) *Messenger { - return &Messenger{ - uid: uid, - channels: [](chan *Event){}, - } -} - -// Register returns a new chan []byte -func (m *Messenger) Register() <-chan *Event { - m.mutex.Lock() - // TODO: Limit the number of messengers per uid - channel := make(chan *Event, 1) - m.channels = append(m.channels, channel) - m.mutex.Unlock() - return channel -} - -// Unregister removes the provider chan []byte -func (m *Messenger) Unregister(channel <-chan *Event) bool { - m.mutex.Lock() - defer m.mutex.Unlock() - for i, toRemove := range m.channels { - if channel == toRemove { - m.channels = append(m.channels[:i], m.channels[i+1:]...) - close(toRemove) - break - } - } - return len(m.channels) == 0 -} - -// UnregisterAll removes all chan []byte -func (m *Messenger) UnregisterAll() { - m.mutex.Lock() - defer m.mutex.Unlock() - for _, channel := range m.channels { - close(channel) - } - m.channels = nil -} - -// SendMessage sends the message to all registered channels -func (m *Messenger) SendMessage(message *Event) { - m.mutex.Lock() - defer m.mutex.Unlock() - for i := range m.channels { - channel := m.channels[i] - select { - case channel <- message: - default: - } - } -} - -// SendMessageBlocking sends the message to all registered channels and ensures it gets sent -func (m *Messenger) SendMessageBlocking(message *Event) { - m.mutex.Lock() - defer m.mutex.Unlock() - for i := range m.channels { - m.channels[i] <- message - } -} diff --git a/routers/common/blockexpensive.go b/routers/common/blockexpensive.go index fec364351c..18a56de72d 100644 --- a/routers/common/blockexpensive.go +++ b/routers/common/blockexpensive.go @@ -72,7 +72,7 @@ func isRoutePathExpensive(routePattern string) bool { } func isRoutePathForLongPolling(routePattern string) bool { - return routePattern == "/user/events" + return routePattern == "/-/ws" } func determineRequestPriority(reqCtx reqctx.RequestContext) (ret struct { diff --git a/routers/common/blockexpensive_test.go b/routers/common/blockexpensive_test.go index db5c0db7dd..eca7d88118 100644 --- a/routers/common/blockexpensive_test.go +++ b/routers/common/blockexpensive_test.go @@ -26,5 +26,5 @@ func TestBlockExpensive(t *testing.T) { assert.Equal(t, c.expensive, isRoutePathExpensive(c.routePath), "routePath: %s", c.routePath) } - assert.True(t, isRoutePathForLongPolling("/user/events")) + assert.True(t, isRoutePathForLongPolling("/-/ws")) } diff --git a/routers/common/qos.go b/routers/common/qos.go index 96f23b64fe..8124999a16 100644 --- a/routers/common/qos.go +++ b/routers/common/qos.go @@ -79,9 +79,9 @@ func QoS() func(next http.Handler) http.Handler { return } - // Release long-polling immediately, so they don't always + // Release long-lived connections immediately, so they don't always // take up an in-flight request - if strings.Contains(req.URL.Path, "/user/events") { + if strings.Contains(req.URL.Path, "/-/ws") { c.Release() } else { defer c.Release() diff --git a/routers/init.go b/routers/init.go index f6775dd8fe..8bad3be0bf 100644 --- a/routers/init.go +++ b/routers/init.go @@ -12,7 +12,6 @@ import ( "code.gitea.io/gitea/models" authmodel "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/cache" - "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/log" @@ -160,8 +159,8 @@ func InitWebInstalled(ctx context.Context) { mustInit(automerge.Init) mustInit(task.Init) mustInit(repo_migrations.Init) - eventsource.GetManager().Init() mustInit(websocket_service.Init) + mustInit(websocket_service.InitStopwatch) mustInitCtx(ctx, mailer_incoming.Init) mustInitCtx(ctx, syncAppConfForGit) diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 1219690200..a85d334fce 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -16,7 +16,6 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/password" - "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -34,6 +33,7 @@ import ( "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/mailer" user_service "code.gitea.io/gitea/services/user" + websocket_service "code.gitea.io/gitea/services/websocket" "github.com/markbates/goth" ) @@ -445,10 +445,7 @@ func HandleSignOut(ctx *context.Context) { // SignOut sign out from login status func SignOut(ctx *context.Context) { if ctx.Doer != nil { - eventsource.GetManager().SendMessageBlocking(ctx.Doer.ID, &eventsource.Event{ - Name: "logout", - Data: ctx.Session.ID(), - }) + websocket_service.PublishLogout(ctx.Doer.ID, ctx.Session.ID()) } // prepare the sign-out URL before destroying the session diff --git a/routers/web/events/events.go b/routers/web/events/events.go deleted file mode 100644 index 52f20e07dc..0000000000 --- a/routers/web/events/events.go +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package events - -import ( - "net/http" - "time" - - "code.gitea.io/gitea/modules/eventsource" - "code.gitea.io/gitea/modules/graceful" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/routers/web/auth" - "code.gitea.io/gitea/services/context" -) - -// Events listens for events -func Events(ctx *context.Context) { - // FIXME: Need to check if resp is actually a http.Flusher! - how though? - - // Set the headers related to event streaming. - ctx.Resp.Header().Set("Content-Type", "text/event-stream") - ctx.Resp.Header().Set("Cache-Control", "no-cache") - ctx.Resp.Header().Set("Connection", "keep-alive") - ctx.Resp.Header().Set("X-Accel-Buffering", "no") - ctx.Resp.WriteHeader(http.StatusOK) - - if !ctx.IsSigned { - // Return unauthorized status event - event := &eventsource.Event{ - Name: "close", - Data: "unauthorized", - } - _, _ = event.WriteTo(ctx) - ctx.Resp.Flush() - return - } - - // Listen to connection close and un-register messageChan - notify := ctx.Done() - ctx.Resp.Flush() - - shutdownCtx := graceful.GetManager().ShutdownContext() - - uid := ctx.Doer.ID - - messageChan := eventsource.GetManager().Register(uid) - - unregister := func() { - eventsource.GetManager().Unregister(uid, messageChan) - // ensure the messageChan is closed - for { - _, ok := <-messageChan - if !ok { - break - } - } - } - - if _, err := ctx.Resp.Write([]byte("\n")); err != nil { - log.Error("Unable to write to EventStream: %v", err) - unregister() - return - } - - timer := time.NewTicker(30 * time.Second) - -loop: - for { - select { - case <-timer.C: - event := &eventsource.Event{ - Name: "ping", - } - _, err := event.WriteTo(ctx.Resp) - if err != nil { - log.Error("Unable to write to EventStream for user %s: %v", ctx.Doer.Name, err) - go unregister() - break loop - } - ctx.Resp.Flush() - case <-notify: - go unregister() - break loop - case <-shutdownCtx.Done(): - go unregister() - break loop - case event, ok := <-messageChan: - if !ok { - break loop - } - - // Handle logout - if event.Name == "logout" { - if ctx.Session.ID() == event.Data { - _, _ = (&eventsource.Event{ - Name: "logout", - Data: "here", - }).WriteTo(ctx.Resp) - ctx.Resp.Flush() - go unregister() - auth.HandleSignOut(ctx) - break loop - } - // Replace the event - we don't want to expose the session ID to the user - event = &eventsource.Event{ - Name: "logout", - Data: "elsewhere", - } - } - - _, err := event.WriteTo(ctx.Resp) - if err != nil { - log.Error("Unable to write to EventStream for user %s: %v", ctx.Doer.Name, err) - go unregister() - break loop - } - ctx.Resp.Flush() - } - } - timer.Stop() -} diff --git a/routers/web/repo/issue_stopwatch.go b/routers/web/repo/issue_stopwatch.go index 2de3a7cfec..a228bf779a 100644 --- a/routers/web/repo/issue_stopwatch.go +++ b/routers/web/repo/issue_stopwatch.go @@ -6,8 +6,8 @@ package repo import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/services/context" + websocket_service "code.gitea.io/gitea/services/websocket" ) // IssueStartStopwatch creates a stopwatch for the given issue. @@ -76,10 +76,7 @@ func CancelStopwatch(c *context.Context) { return } if len(stopwatches) == 0 { - eventsource.GetManager().SendMessage(c.Doer.ID, &eventsource.Event{ - Name: "stopwatches", - Data: "{}", - }) + websocket_service.PublishEmptyStopwatches(c.Doer.ID) } c.JSONRedirect("") diff --git a/routers/web/web.go b/routers/web/web.go index 6cf209a886..bb8c6a4ef7 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -27,7 +27,6 @@ import ( "code.gitea.io/gitea/routers/web/admin" "code.gitea.io/gitea/routers/web/auth" "code.gitea.io/gitea/routers/web/devtest" - "code.gitea.io/gitea/routers/web/events" "code.gitea.io/gitea/routers/web/explore" "code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/routers/web/healthcheck" @@ -592,7 +591,6 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }) }, reqSignOut) - m.Any("/user/events", routing.MarkLongPolling, events.Events) m.Get("/-/ws", gitea_websocket.Serve) m.Group("/login/oauth", func() { diff --git a/routers/web/websocket/websocket.go b/routers/web/websocket/websocket.go index b4d9619f6d..6fa4b76a08 100644 --- a/routers/web/websocket/websocket.go +++ b/routers/web/websocket/websocket.go @@ -6,6 +6,7 @@ package websocket import ( "net/http" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/pubsub" @@ -13,6 +14,38 @@ import ( gitea_ws "github.com/coder/websocket" ) +// logoutBrokerMsg is the internal broker message published by PublishLogout. +type logoutBrokerMsg struct { + Type string `json:"type"` + SessionID string `json:"sessionID,omitempty"` +} + +// logoutClientMsg is sent to the WebSocket client so the browser can tell +// whether the logout originated from this tab ("here") or another ("elsewhere"). +type logoutClientMsg struct { + Type string `json:"type"` + Data string `json:"data"` +} + +// rewriteLogout intercepts a broker logout message and rewrites it to the +// client format using "here"/"elsewhere" instead of the raw session ID. +// If sessionID is empty the logout applies to all sessions ("here" for all). +func rewriteLogout(msg []byte, connSessionID string) []byte { + var lm logoutBrokerMsg + if err := json.Unmarshal(msg, &lm); err != nil || lm.Type != "logout" { + return msg + } + where := "elsewhere" + if lm.SessionID == "" || lm.SessionID == connSessionID { + where = "here" + } + out, err := json.Marshal(logoutClientMsg{Type: "logout", Data: where}) + if err != nil { + return msg + } + return out +} + // Serve handles WebSocket upgrade and event delivery for the signed-in user. func Serve(ctx *context.Context) { if !ctx.IsSigned { @@ -28,6 +61,7 @@ func Serve(ctx *context.Context) { } defer conn.CloseNow() //nolint:errcheck // CloseNow is best-effort; error is intentionally ignored + sessionID := ctx.Session.ID() ch, cancel := pubsub.DefaultBroker.Subscribe(pubsub.UserTopic(ctx.Doer.ID)) defer cancel() @@ -40,6 +74,7 @@ func Serve(ctx *context.Context) { if !ok { return } + msg = rewriteLogout(msg, sessionID) if err := conn.Write(wsCtx, gitea_ws.MessageText, msg); err != nil { log.Trace("websocket: write failed: %v", err) return diff --git a/services/user/user.go b/services/user/user.go index 9b8bcf83c0..b540bc5909 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -16,7 +16,6 @@ import ( repo_model "code.gitea.io/gitea/models/repo" system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" @@ -28,6 +27,7 @@ import ( "code.gitea.io/gitea/services/packages" container_service "code.gitea.io/gitea/services/packages/container" repo_service "code.gitea.io/gitea/services/repository" + websocket_service "code.gitea.io/gitea/services/websocket" ) // RenameUser renames a user @@ -147,9 +147,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { // Force any logged in sessions to log out // FIXME: We also need to tell the session manager to log them out too. - eventsource.GetManager().SendMessage(u.ID, &eventsource.Event{ - Name: "logout", - }) + websocket_service.PublishLogout(u.ID, "") // Delete all repos belonging to this user // Now this is not within a transaction because there are internal transactions within the DeleteRepository diff --git a/services/websocket/logout_publisher.go b/services/websocket/logout_publisher.go new file mode 100644 index 0000000000..91492fcc66 --- /dev/null +++ b/services/websocket/logout_publisher.go @@ -0,0 +1,30 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/pubsub" +) + +type logoutEvent struct { + Type string `json:"type"` + SessionID string `json:"sessionID,omitempty"` +} + +// PublishLogout publishes a logout event to all WebSocket clients connected as +// the given user. sessionID identifies which session is signing out so the +// client can distinguish "this tab" from "another tab". +func PublishLogout(userID int64, sessionID string) { + msg, err := json.Marshal(logoutEvent{ + Type: "logout", + SessionID: sessionID, + }) + if err != nil { + log.Error("websocket: marshal logout event: %v", err) + return + } + pubsub.DefaultBroker.Publish(pubsub.UserTopic(userID), msg) +} diff --git a/services/websocket/stopwatch_notifier.go b/services/websocket/stopwatch_notifier.go new file mode 100644 index 0000000000..502b9afcaf --- /dev/null +++ b/services/websocket/stopwatch_notifier.go @@ -0,0 +1,108 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "context" + "time" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/pubsub" +) + +type stopwatchesEvent struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// PublishEmptyStopwatches immediately pushes an empty stopwatches list to the +// given user's WebSocket clients — used when the user's last stopwatch is cancelled. +func PublishEmptyStopwatches(userID int64) { + msg, err := json.Marshal(stopwatchesEvent{ + Type: "stopwatches", + Data: json.RawMessage(`[]`), + }) + if err != nil { + log.Error("websocket: marshal empty stopwatches: %v", err) + return + } + pubsub.DefaultBroker.Publish(pubsub.UserTopic(userID), msg) +} + +// InitStopwatch starts the background goroutine that polls active stopwatches +// and pushes updates to connected WebSocket clients. +func InitStopwatch() error { + if !setting.Service.EnableTimetracking { + return nil + } + go graceful.GetManager().RunWithShutdownContext(runStopwatch) + return nil +} + +func runStopwatch(ctx context.Context) { + ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: WebSocket Stopwatch", process.SystemProcessType, true) + defer finished() + + if setting.UI.Notification.EventSourceUpdateTime <= 0 { + return + } + + timer := time.NewTicker(setting.UI.Notification.EventSourceUpdateTime) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + if !pubsub.DefaultBroker.HasSubscribers() { + continue + } + + userStopwatches, err := issues_model.GetUIDsAndStopwatch(ctx) + if err != nil { + log.Error("websocket: GetUIDsAndStopwatch: %v", err) + continue + } + + for _, us := range userStopwatches { + u, err := user_model.GetUserByID(ctx, us.UserID) + if err != nil { + log.Error("websocket: GetUserByID %d: %v", us.UserID, err) + continue + } + + apiSWs, err := convert.ToStopWatches(ctx, u, us.StopWatches) + if err != nil { + if !issues_model.IsErrIssueNotExist(err) { + log.Error("websocket: ToStopWatches: %v", err) + } + continue + } + + dataBs, err := json.Marshal(apiSWs) + if err != nil { + log.Error("websocket: marshal stopwatches: %v", err) + continue + } + + msg, err := json.Marshal(stopwatchesEvent{ + Type: "stopwatches", + Data: dataBs, + }) + if err != nil { + continue + } + pubsub.DefaultBroker.Publish(pubsub.UserTopic(us.UserID), msg) + } + } + } +} diff --git a/tests/integration/eventsource_test.go b/tests/integration/eventsource_test.go deleted file mode 100644 index a13a8c346a..0000000000 --- a/tests/integration/eventsource_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - activities_model "code.gitea.io/gitea/models/activities" - auth_model "code.gitea.io/gitea/models/auth" - repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/models/unittest" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/eventsource" - api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/tests" - - "github.com/stretchr/testify/assert" -) - -func TestEventSourceManagerRun(t *testing.T) { - defer tests.PrepareTestEnv(t)() - manager := eventsource.GetManager() - - eventChan := manager.Register(2) - defer func() { - manager.Unregister(2, eventChan) - // ensure the eventChan is closed - for { - _, ok := <-eventChan - if !ok { - break - } - } - }() - expectNotificationCountEvent := func(count int64) func() bool { - return func() bool { - select { - case event, ok := <-eventChan: - if !ok { - return false - } - data, ok := event.Data.(activities_model.UserIDCount) - if !ok { - return false - } - return event.Name == "notification-count" && data.Count == count - default: - return false - } - } - } - - user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5}) - assert.NoError(t, thread5.LoadAttributes(t.Context())) - session := loginUser(t, user2.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteNotification, auth_model.AccessTokenScopeWriteRepository) - - var apiNL []api.NotificationThread - - // -- mark notifications as read -- - req := NewRequest(t, "GET", "/api/v1/notifications?status-types=unread"). - AddTokenAuth(token) - resp := session.MakeRequest(t, req, http.StatusOK) - - DecodeJSON(t, resp, &apiNL) - assert.Len(t, apiNL, 2) - - lastReadAt := "2000-01-01T00%3A50%3A01%2B00%3A00" // 946687801 <- only Notification 4 is in this filter ... - req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?last_read_at=%s", user2.Name, repo1.Name, lastReadAt)). - AddTokenAuth(token) - session.MakeRequest(t, req, http.StatusResetContent) - - req = NewRequest(t, "GET", "/api/v1/notifications?status-types=unread"). - AddTokenAuth(token) - resp = session.MakeRequest(t, req, http.StatusOK) - DecodeJSON(t, resp, &apiNL) - assert.Len(t, apiNL, 1) - - assert.Eventually(t, expectNotificationCountEvent(1), 30*time.Second, 1*time.Second) -} diff --git a/web_src/js/features/eventsource.sharedworker.ts b/web_src/js/features/eventsource.sharedworker.ts index 58b371e6a0..370ec06533 100644 --- a/web_src/js/features/eventsource.sharedworker.ts +++ b/web_src/js/features/eventsource.sharedworker.ts @@ -1,20 +1,13 @@ +// Source manages the list of connected page ports for one logical connection. +// It no longer creates an EventSource; all real-time data is delivered by the +// accompanying WsSource over WebSocket. class Source { url: string; - eventSource: EventSource | null; - listening: Record; clients: Array; constructor(url: string) { this.url = url; - this.eventSource = new EventSource(url); - this.listening = {}; this.clients = []; - this.listen('open'); - this.listen('close'); - this.listen('logout'); - this.listen('notification-count'); - this.listen('stopwatches'); - this.listen('error'); } register(port: MessagePort) { @@ -37,24 +30,6 @@ class Source { return this.clients.length; } - close() { - if (!this.eventSource) return; - - this.eventSource.close(); - this.eventSource = null; - } - - listen(eventType: string) { - if (this.listening[eventType]) return; - this.listening[eventType] = true; - this.eventSource?.addEventListener(eventType, (event) => { - this.notifyClients({ - type: eventType, - data: event.data, - }); - }); - } - notifyClients(event: {type: string, data: any}) { for (const client of this.clients) { client.postMessage(event); @@ -64,15 +39,14 @@ class Source { status(port: MessagePort) { port.postMessage({ type: 'status', - message: `url: ${this.url} readyState: ${this.eventSource?.readyState}`, + message: `url: ${this.url}`, }); } } -// WsSource provides a WebSocket transport alongside EventSource. -// It delivers real-time notification-count pushes using the same client list -// as the associated Source, normalising messages to the SSE event format so -// that callers do not need to know which transport delivered the event. +// WsSource provides a WebSocket transport for real-time event delivery. +// It normalises messages to the SSE event format so that callers do not +// need to know which transport delivered the event. class WsSource { wsUrl: string; ws: WebSocket | null; @@ -105,6 +79,16 @@ class WsSource { type: 'notification-count', data: JSON.stringify({Count: msg.count}), }); + } else if (msg.type === 'stopwatches') { + this.source.notifyClients({ + type: 'stopwatches', + data: JSON.stringify(msg.data), + }); + } else if (msg.type === 'logout') { + this.source.notifyClients({ + type: 'logout', + data: msg.data ?? '', + }); } } catch { // ignore malformed messages @@ -149,13 +133,6 @@ const wsSourcesByUrl = new Map(); (self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => { for (const port of e.ports) { port.addEventListener('message', (event: MessageEvent) => { - if (!self.EventSource) { - // some browsers (like PaleMoon, Firefox<53) don't support EventSource in SharedWorkerGlobalScope. - // this event handler needs EventSource when doing "new Source(url)", so just post a message back to the caller, - // in case the caller would like to use a fallback method to do its work. - port.postMessage({type: 'no-event-source'}); - return; - } if (event.data.type === 'start') { const url = event.data.url; let source = sourcesByUrl.get(url); @@ -167,14 +144,13 @@ const wsSourcesByUrl = new Map(); } source = sourcesByPort.get(port); if (source) { - if (source.eventSource && source.url === url) return; + if (source.url === url) return; // How this has happened I don't understand... // deregister from that source const count = source.deregister(port); // Clean-up if (count === 0) { - source.close(); sourcesByUrl.set(source.url, null); const ws = wsSourcesByUrl.get(source.url); if (ws) { @@ -183,24 +159,19 @@ const wsSourcesByUrl = new Map(); } } } - // Create a new Source + // Create a new Source and its WebSocket transport source = new Source(url); source.register(port); sourcesByUrl.set(url, source); sourcesByPort.set(port, source); - // Start WebSocket alongside EventSource for real-time notification pushes. const wsUrl = url.replace(/^http/, 'ws').replace(/\/user\/events$/, '/-/ws'); wsSourcesByUrl.set(url, new WsSource(wsUrl, source)); - } else if (event.data.type === 'listen') { - const source = sourcesByPort.get(port)!; - source.listen(event.data.eventType); } else if (event.data.type === 'close') { const source = sourcesByPort.get(port); if (!source) return; const count = source.deregister(port); if (count === 0) { - source.close(); sourcesByUrl.set(source.url, null); sourcesByPort.set(port, null); const ws = wsSourcesByUrl.get(source.url); From bf7c30e2824768aba6cb076400a6ccf17843cd10 Mon Sep 17 00:00:00 2001 From: Epid Date: Thu, 2 Apr 2026 00:54:08 +0300 Subject: [PATCH 27/33] fix(websocket): replace json.RawMessage with any in stopwatchesEvent --- services/websocket/stopwatch_notifier.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/services/websocket/stopwatch_notifier.go b/services/websocket/stopwatch_notifier.go index 502b9afcaf..df6458a8ba 100644 --- a/services/websocket/stopwatch_notifier.go +++ b/services/websocket/stopwatch_notifier.go @@ -19,8 +19,8 @@ import ( ) type stopwatchesEvent struct { - Type string `json:"type"` - Data json.RawMessage `json:"data"` + Type string `json:"type"` + Data any `json:"data"` } // PublishEmptyStopwatches immediately pushes an empty stopwatches list to the @@ -28,7 +28,7 @@ type stopwatchesEvent struct { func PublishEmptyStopwatches(userID int64) { msg, err := json.Marshal(stopwatchesEvent{ Type: "stopwatches", - Data: json.RawMessage(`[]`), + Data: []any{}, }) if err != nil { log.Error("websocket: marshal empty stopwatches: %v", err) @@ -88,15 +88,9 @@ func runStopwatch(ctx context.Context) { continue } - dataBs, err := json.Marshal(apiSWs) - if err != nil { - log.Error("websocket: marshal stopwatches: %v", err) - continue - } - msg, err := json.Marshal(stopwatchesEvent{ Type: "stopwatches", - Data: dataBs, + Data: apiSWs, }) if err != nil { continue From 9cf7ea8a90bd94f4d253e4dc53b6bfe6ebbeeba6 Mon Sep 17 00:00:00 2001 From: Epid Date: Thu, 2 Apr 2026 02:40:11 +0300 Subject: [PATCH 28/33] fix(stopwatch): show icon dynamically in tabs loaded before stopwatch starts The stopwatch navbar icon and popup were only rendered by the server when a stopwatch was already active at page load. If a tab was opened before the stopwatch started, `initStopwatch()` found no `.active-stopwatch` element in the DOM, returned early, and never registered a SharedWorker listener. As a result the WebSocket push from the stopwatch notifier had nowhere to land and the icon never appeared. Fix by always rendering both the icon anchor and the popup skeleton in the navbar (hidden with `tw-hidden` when no stopwatch is active). `initStopwatch()` can now set up the SharedWorker in every tab, and `updateStopwatchData` can call `showElem`/`hideElem` as stopwatch state changes arrive in real time. Also add `onShow` to `createTippy` so the popup content is re-cloned from the (JS-updated) original each time the tooltip opens, keeping it current even when the stopwatch was started after page load. Add a new e2e test (`stopwatch appears via real-time push`) that verifies the icon appears after `apiStartStopwatch` is called with the page already loaded. --- templates/base/head_navbar.tmpl | 46 +++++++++++++-------------- templates/base/head_navbar_icons.tmpl | 4 +-- tests/e2e/events.test.ts | 24 +++++++++++++- web_src/js/features/stopwatch.ts | 5 +++ 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 447f78565e..5b8593ec43 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -152,31 +152,29 @@ {{$activeStopwatch := and .PageGlobalData (call .PageGlobalData.GetActiveStopwatch)}} - {{if $activeStopwatch}} -
-
- - {{svg "octicon-issue-opened" 16}} - {{$activeStopwatch.RepoSlug}}#{{$activeStopwatch.IssueIndex}} - -
-
- -
-
- -
-
+ {{template "base/head_banner"}} diff --git a/templates/base/head_navbar_icons.tmpl b/templates/base/head_navbar_icons.tmpl index 89b02389fc..e0c997b88a 100644 --- a/templates/base/head_navbar_icons.tmpl +++ b/templates/base/head_navbar_icons.tmpl @@ -3,14 +3,12 @@ {{if and $data $data.IsSigned}}{{/* data may not exist, for example: rendering 503 page before the PageGlobalData middleware */}} {{- $activeStopwatch := call $data.GetActiveStopwatch -}} {{- $notificationUnreadCount := call $data.GetNotificationUnreadCount -}} - {{if $activeStopwatch}} - +
{{svg "octicon-stopwatch"}}
- {{end}}
{{svg "octicon-bell"}} diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index 61f1a3c881..bab3430945 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -29,7 +29,7 @@ test.describe('events', () => { await Promise.all([apiDeleteUser(request, commenter), apiDeleteUser(request, owner)]); }); - test('stopwatch', async ({page, request}) => { + test('stopwatch visible at page load', async ({page, request}) => { const name = `ev-sw-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const headers = apiUserHeaders(name); @@ -51,6 +51,28 @@ test.describe('events', () => { await apiDeleteUser(request, name); }); + test('stopwatch appears via real-time push', async ({page, request}) => { + const name = `ev-sw-push-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const headers = apiUserHeaders(name); + + await apiCreateUser(request, name); + await apiCreateRepo(request, {name, headers}); + await apiCreateIssue(request, name, name, {title: 'events stopwatch push test', headers}); + + // Login before starting stopwatch — page loads without active stopwatch + await loginUser(page, name); + + const stopwatch = page.locator('.active-stopwatch.not-mobile'); + await expect(stopwatch).toBeHidden(); + + // Start stopwatch after page is loaded — icon should appear via WebSocket push + await apiStartStopwatch(request, name, name, 1, {headers}); + await expect(stopwatch).toBeVisible({timeout: 15000}); + + // Cleanup + await apiDeleteUser(request, name); + }); + test('logout propagation', async ({browser, request}) => { const name = `ev-logout-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index 6fa8fbbdf3..75f6290735 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -34,6 +34,11 @@ export function initStopwatch() { interactive: true, hideOnClick: true, theme: 'default', + onShow(instance) { + // Re-clone so the tooltip always reflects the latest stopwatch state, + // even when the icon became visible via a real-time WebSocket push. + instance.setContent(stopwatchPopup.cloneNode(true) as Element); + }, }); } From 0dc3607cf7a47df38267296e08cdef57602088c4 Mon Sep 17 00:00:00 2001 From: Epid Date: Thu, 2 Apr 2026 03:59:13 +0300 Subject: [PATCH 29/33] fix(stopwatch): publish empty-stopwatches push on stop, not only cancel --- routers/web/repo/issue_stopwatch.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/routers/web/repo/issue_stopwatch.go b/routers/web/repo/issue_stopwatch.go index a228bf779a..81ee07f140 100644 --- a/routers/web/repo/issue_stopwatch.go +++ b/routers/web/repo/issue_stopwatch.go @@ -51,6 +51,16 @@ func IssueStopStopwatch(c *context.Context) { } else if !ok { c.Flash.Warning(c.Tr("repo.issues.stopwatch_already_stopped")) } + + stopwatches, err := issues_model.GetUserStopwatches(c, c.Doer.ID, db.ListOptions{}) + if err != nil { + c.ServerError("GetUserStopwatches", err) + return + } + if len(stopwatches) == 0 { + websocket_service.PublishEmptyStopwatches(c.Doer.ID) + } + c.JSONRedirect("") } From 075af8eaf5dbf4dfb01fcc8b8d75de257a986f62 Mon Sep 17 00:00:00 2001 From: Epid Date: Thu, 2 Apr 2026 04:55:34 +0300 Subject: [PATCH 30/33] fix(stopwatch): prevent page reload when stopping/cancelling from navbar popup The navbar popup stop/cancel forms used form-fetch-action, which always reloads the page when the server returns an empty redirect. Remove that class and add a dedicated delegated submit handler in stopwatch.ts that POSTs the action silently; the WebSocket push (or periodic poller) then updates the icon without any navigation. --- templates/base/head_navbar.tmpl | 4 ++-- web_src/js/features/stopwatch.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 5b8593ec43..10590cca66 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -159,14 +159,14 @@ {{if $activeStopwatch}}{{$activeStopwatch.RepoSlug}}#{{$activeStopwatch.IssueIndex}}{{end}}
-
+
-
+
-
- {{if $.IsStopwatchRunning}} - - - {{else}} -
+
+ - {{end}}
- {{if and (not $.IsStopwatchRunning) .HasUserStopwatch}} -
{{ctx.Locale.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL}}
- {{end}} +
{{ctx.Locale.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL}}
{{if .Issue.TimeEstimate}}
{{ctx.Locale.Tr "repo.issues.time_estimate_display" (TimeEstimateString .Issue.TimeEstimate)}}
diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index 8a6593ee4c..fb878e4bba 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -52,6 +52,35 @@ export function initStopwatch() { await POST(action, {data: new FormData(form)}); }); + // Handle start/stop/cancel from the issue sidebar without a page reload. + // Buttons toggle between the two groups (.issue-start-buttons / .issue-stop-cancel-buttons) + // immediately; the navbar icon is updated by the WebSocket push or periodic poller. + addDelegatedEventListener(document, 'click', '.issue-start-time,.issue-stop-time,.issue-cancel-time', async (btn: HTMLElement, e: MouseEvent) => { + e.preventDefault(); + const url = btn.getAttribute('data-url'); + if (!url) return; + + const startGroup = document.querySelector('.issue-start-buttons'); + const stopGroup = document.querySelector('.issue-stop-cancel-buttons'); + const isStart = btn.classList.contains('issue-start-time'); + + btn.classList.add('is-loading'); + try { + const resp = await POST(url); + if (!resp.ok) return; + // Toggle sidebar button groups immediately, no reload needed. + if (isStart) { + hideElem(startGroup); + showElem(stopGroup); + } else { + hideElem(stopGroup); + showElem(startGroup); + } + } finally { + btn.classList.remove('is-loading'); + } + }); + let usingPeriodicPoller = false; const startPeriodicPoller = (timeout: number) => { if (timeout <= 0 || !Number.isFinite(timeout)) return; From 10f8e1ec06bf9460b7f1e4d295a003ed73df24b5 Mon Sep 17 00:00:00 2001 From: Epid Date: Thu, 2 Apr 2026 08:05:00 +0300 Subject: [PATCH 32/33] chore(stopwatch): remove dead no-event-source code paths The shared worker no longer emits 'no-event-source' messages since the EventSource transport was removed. Clean up the unreachable branches in both notification.ts and stopwatch.ts. Co-Authored-By: Claude Sonnet 4.6 --- web_src/js/features/notification.ts | 6 +----- web_src/js/features/stopwatch.ts | 11 +++-------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/web_src/js/features/notification.ts b/web_src/js/features/notification.ts index acb1b68f28..8483a28299 100644 --- a/web_src/js/features/notification.ts +++ b/web_src/js/features/notification.ts @@ -21,10 +21,8 @@ async function receiveUpdateCount(event: MessageEvent<{type: string, data: strin export function initNotificationCount() { if (!document.querySelector('.notification_count')) return; - let usingPeriodicPoller = false; const startPeriodicPoller = (timeout: number, lastCount?: number) => { if (timeout <= 0 || !Number.isFinite(timeout)) return; - usingPeriodicPoller = true; lastCount = lastCount ?? getCurrentCount(); setTimeout(async () => { await updateNotificationCountWithCallback(startPeriodicPoller, timeout, lastCount); @@ -35,9 +33,7 @@ export function initNotificationCount() { // Try to connect to the event source via the shared worker first const worker = new UserEventsSharedWorker('notification-worker'); worker.addMessageEventListener((event: MessageEvent) => { - if (event.data.type === 'no-event-source') { - if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout); - } else if (event.data.type === 'notification-count') { + if (event.data.type === 'notification-count') { receiveUpdateCount(event); // no await } }); diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index fb878e4bba..c5d68c5595 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -60,8 +60,8 @@ export function initStopwatch() { const url = btn.getAttribute('data-url'); if (!url) return; - const startGroup = document.querySelector('.issue-start-buttons'); - const stopGroup = document.querySelector('.issue-stop-cancel-buttons'); + const startGroup = document.querySelector('.issue-start-buttons')!; + const stopGroup = document.querySelector('.issue-stop-cancel-buttons')!; const isStart = btn.classList.contains('issue-start-time'); btn.classList.add('is-loading'); @@ -81,10 +81,8 @@ export function initStopwatch() { } }); - let usingPeriodicPoller = false; const startPeriodicPoller = (timeout: number) => { if (timeout <= 0 || !Number.isFinite(timeout)) return; - usingPeriodicPoller = true; setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout); }; @@ -93,10 +91,7 @@ export function initStopwatch() { // Try to connect to the event source via the shared worker first const worker = new UserEventsSharedWorker('stopwatch-worker'); worker.addMessageEventListener((event) => { - if (event.data.type === 'no-event-source') { - // browser doesn't support EventSource, falling back to periodic poller - if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout); - } else if (event.data.type === 'stopwatches') { + if (event.data.type === 'stopwatches') { updateStopwatchData(JSON.parse(event.data.data)); } }); From 99ad25bdd0c2552a54b0f02e6a54f14d87f529b8 Mon Sep 17 00:00:00 2001 From: Epid Date: Thu, 2 Apr 2026 11:24:52 +0300 Subject: [PATCH 33/33] chore: remove local CI workflow (not for upstream) This Gitea Actions workflow was used for local compliance testing on git.epid.co and should not be part of the upstream PR. --- .gitea/workflows/compliance.yml | 213 -------------------------------- 1 file changed, 213 deletions(-) delete mode 100644 .gitea/workflows/compliance.yml diff --git a/.gitea/workflows/compliance.yml b/.gitea/workflows/compliance.yml deleted file mode 100644 index 6f9d44c375..0000000000 --- a/.gitea/workflows/compliance.yml +++ /dev/null @@ -1,213 +0,0 @@ -name: PR Compliance -on: - push: - branches: ["ci/**"] - -jobs: - lint-backend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - run: make deps-backend deps-tools - - run: make lint-backend - env: - TAGS: bindata sqlite sqlite_unlock_notify - - name: cleanup - if: always() - run: go clean -cache -testcache 2>/dev/null || true - - lint-go-windows: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - run: make deps-backend deps-tools - - run: make lint-go-windows lint-go-gitea-vet - env: - TAGS: bindata sqlite sqlite_unlock_notify - GOOS: windows - GOARCH: amd64 - - name: cleanup - if: always() - run: go clean -cache -testcache 2>/dev/null || true - - lint-go-gogit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - run: make deps-backend deps-tools - - run: make lint-go - env: - TAGS: bindata gogit sqlite sqlite_unlock_notify - - name: cleanup - if: always() - run: go clean -cache -testcache 2>/dev/null || true - - checks-backend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - run: make deps-backend deps-tools - - run: make --always-make checks-backend - - name: cleanup - if: always() - run: go clean -cache -testcache 2>/dev/null || true - - lint-spell: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - run: make lint-spell - - name: cleanup - if: always() - run: go clean -cache -testcache 2>/dev/null || true - - frontend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - run: make deps-frontend - - run: make lint-frontend - - run: make checks-frontend - - run: make test-frontend - - run: make frontend - - name: cleanup - if: always() - run: rm -rf dist/ node_modules/.cache 2>/dev/null || true - - lint-json: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - run: make deps-frontend - - run: make lint-json - - lint-swagger: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - run: make deps-frontend - - run: make lint-swagger - - lint-yaml: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - - run: uv python install 3.12 - - run: make deps-py - - run: make lint-yaml - - lint-templates: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - - run: uv python install 3.12 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - run: make deps-py - - run: make deps-frontend - - run: make lint-templates - - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - run: make deps-frontend - - run: make lint-md - - backend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - run: make deps-backend - - run: go build -o gitea_no_gcc - - name: cleanup-before-cross-compile - run: go clean -cache && rm -f gitea_no_gcc - - name: build-backend-arm64 - run: make backend - env: - GOOS: linux - GOARCH: arm64 - TAGS: bindata gogit - - name: cleanup-arm64 - run: go clean -cache && rm -f gitea - - name: build-backend-windows - run: go build -o gitea_windows - env: - GOOS: windows - GOARCH: amd64 - TAGS: bindata gogit - - name: cleanup-windows - run: go clean -cache && rm -f gitea_windows - - name: build-backend-386 - run: go build -o gitea_linux_386 - env: - GOOS: linux - GOARCH: 386 - - name: cleanup - if: always() - run: go clean -cache -testcache && rm -f gitea_linux_386 2>/dev/null || true - - actions: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - run: make lint-actions - - name: cleanup - if: always() - run: go clean -cache -testcache 2>/dev/null || true