feat: Replace SSE with WebSocket for UI notifications (#36965)

* Closes #36942
* Fixes #19265 

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

### What changes

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

### What's removed

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

### Stopwatch multi-tab fix

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

### Deployment note

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

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

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

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Epid <rexmrj@gmail.com>
This commit is contained in:
mohammad rahimi
2026-07-27 10:46:00 +03:00
committed by GitHub
parent e15a7e9066
commit 13d0f24423
77 changed files with 2070 additions and 1151 deletions
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package eventsource
import (
"bytes"
"fmt"
"io"
"time"
"gitea.dev/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
}
-50
View File
@@ -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())
})
}
}
-89
View File
@@ -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)
}
}
-122
View File
@@ -1,122 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package eventsource
import (
"context"
"time"
activities_model "gitea.dev/models/activities"
issues_model "gitea.dev/models/issues"
user_model "gitea.dev/models/user"
"gitea.dev/modules/graceful"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/process"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/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()
}
-77
View File
@@ -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
}
}
+6
View File
@@ -23,6 +23,7 @@ type Decoder interface {
// Interface represents an interface to handle json data
type Interface interface {
Marshal(v any) ([]byte, error)
MarshalWrite(w io.Writer, v any) error
Unmarshal(data []byte, v any) error
NewEncoder(writer io.Writer) Encoder
NewDecoder(reader io.Reader) Decoder
@@ -36,6 +37,11 @@ func Marshal(v any) ([]byte, error) {
return DefaultJSONHandler.Marshal(v)
}
// MarshalWrite writes the JSON encoding of v to the given writer
func MarshalWrite(w io.Writer, v any) error {
return DefaultJSONHandler.MarshalWrite(w, v)
}
// Unmarshal decodes object from bytes
func Unmarshal(data []byte, v any) error {
return DefaultJSONHandler.Unmarshal(data, v)
+4
View File
@@ -17,6 +17,10 @@ func (jsonV1) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (jsonV1) MarshalWrite(w io.Writer, v any) error {
return json.NewEncoder(w).Encode(v)
}
func (jsonV1) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
+4
View File
@@ -53,6 +53,10 @@ func (j *JSONv2) Marshal(v any) ([]byte, error) {
return jsonv2.Marshal(v, j.marshalOptions)
}
func (j *JSONv2) MarshalWrite(w io.Writer, v any) error {
return jsonv2.MarshalWrite(w, v, j.marshalOptions)
}
func (j *JSONv2) Unmarshal(data []byte, v any) error {
return jsonv2.Unmarshal(data, v, j.unmarshalOptions)
}
+4 -57
View File
@@ -4,68 +4,15 @@
package queue
import (
"context"
"os"
"os/exec"
"testing"
"time"
"gitea.dev/modules/nosql"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func waitRedisReady(conn string, dur time.Duration) (ready bool) {
ctxTimed, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
for t := time.Now(); ; time.Sleep(50 * time.Millisecond) {
ret := nosql.GetManager().GetRedisClient(conn).Ping(ctxTimed)
if ret.Err() == nil {
return true
}
if time.Since(t) > dur {
return false
}
}
}
func redisServerCmd(t *testing.T) *exec.Cmd {
redisServerProg, err := exec.LookPath("redis-server")
if err != nil {
return nil
}
c := &exec.Cmd{
Path: redisServerProg,
Args: []string{redisServerProg, "--bind", "127.0.0.1", "--port", "6379"},
Dir: t.TempDir(),
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
return c
}
func TestBaseRedis(t *testing.T) {
var redisServer *exec.Cmd
defer func() {
if redisServer != nil {
_ = redisServer.Process.Signal(os.Interrupt)
_ = redisServer.Wait()
}
}()
if !waitRedisReady("redis://127.0.0.1:6379/0", 0) {
redisServer = redisServerCmd(t)
if redisServer == nil && test.AllowSkipExternalService() {
t.Skip("redis server command not found, skipped")
}
require.NotNil(t, redisServer)
assert.NoError(t, redisServer.Start())
require.True(t, waitRedisReady("redis://127.0.0.1:6379/0", 5*time.Second), "start redis-server")
}
testQueueBasic(t, newBaseRedisSimple, toBaseConfig("baseRedis", setting.QueueSettings{Length: 10}), false)
testQueueBasic(t, newBaseRedisUnique, toBaseConfig("baseRedisUnique", setting.QueueSettings{Length: 10}), true)
redisConn := test.PrepareTestRedis(t)
queueSetting := setting.QueueSettings{Length: 10, ConnStr: redisConn}
testQueueBasic(t, newBaseRedisSimple, toBaseConfig("baseRedis", queueSetting), false)
testQueueBasic(t, newBaseRedisUnique, toBaseConfig("baseRedisUnique", queueSetting), true)
}
+1
View File
@@ -221,6 +221,7 @@ func LoadSettings() {
loadServiceFrom(CfgProvider)
loadOAuth2ClientFrom(CfgProvider)
loadCacheFrom(CfgProvider)
loadWebsocketFrom(CfgProvider)
loadSessionFrom(CfgProvider)
loadCorsFrom(CfgProvider)
loadMailsFrom(CfgProvider)
+9 -12
View File
@@ -52,10 +52,9 @@ var UI = struct {
DefaultShowFullName bool
Notification struct {
MinTimeout time.Duration
TimeoutStep time.Duration
MaxTimeout time.Duration
EventSourceUpdateTime time.Duration
MinTimeout time.Duration
TimeoutStep time.Duration
MaxTimeout time.Duration
} `ini:"ui.notification"`
SVG struct {
@@ -107,15 +106,13 @@ var UI = struct {
AmbiguousUnicodeDetection: true,
Notification: struct {
MinTimeout time.Duration
TimeoutStep time.Duration
MaxTimeout time.Duration
EventSourceUpdateTime time.Duration
MinTimeout time.Duration
TimeoutStep time.Duration
MaxTimeout time.Duration
}{
MinTimeout: 10 * time.Second,
TimeoutStep: 10 * time.Second,
MaxTimeout: 60 * time.Second,
EventSourceUpdateTime: 10 * time.Second,
MinTimeout: 10 * time.Second,
TimeoutStep: 10 * time.Second,
MaxTimeout: 60 * time.Second,
},
SVG: struct {
Enabled bool `ini:"ENABLE_RENDER"`
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import "gitea.dev/modules/log"
const (
PubsubTypeMemory = "memory"
PubsubTypeRedis = "redis"
)
// Websocket holds the settings for the websocket event delivery. The pubsub
// backend is scoped to websocket messages only, it is not a general-purpose
// pubsub service.
type WebsocketConfig struct {
PubsubType string
PubsubConnStr string
}
var Websocket = WebsocketConfig{
PubsubType: PubsubTypeMemory,
}
func loadWebsocketFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("websocket")
Websocket.PubsubType = sec.Key("PUBSUB_TYPE").In(PubsubTypeMemory, []string{PubsubTypeMemory, PubsubTypeRedis})
if Websocket.PubsubType == PubsubTypeRedis {
Websocket.PubsubConnStr = sec.Key("PUBSUB_CONN_STR").MustString(Redis.ConnStr)
if Websocket.PubsubConnStr == "" {
log.Fatal("[websocket].PUBSUB_CONN_STR is required when PUBSUB_TYPE = redis")
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestLoadWebsocketConfig(t *testing.T) {
cases := []struct {
name string
ini string
wantType string
wantConn string
}{
{
name: "defaults to memory",
wantType: PubsubTypeMemory,
},
{
name: "redis with its own conn str",
ini: "[websocket]\nPUBSUB_TYPE = redis\nPUBSUB_CONN_STR = redis://127.0.0.1:6379/0",
wantType: PubsubTypeRedis,
wantConn: "redis://127.0.0.1:6379/0",
},
{
name: "redis falls back to the shared [redis] section",
ini: "[redis]\nCONN_STR = redis://127.0.0.1:6379/0\n[websocket]\nPUBSUB_TYPE = redis",
wantType: PubsubTypeRedis,
wantConn: "redis://127.0.0.1:6379/0",
},
{
name: "own conn str wins over the shared one",
ini: "[redis]\nCONN_STR = redis://127.0.0.1:6379/0\n[websocket]\nPUBSUB_TYPE = redis\nPUBSUB_CONN_STR = redis://10.0.0.1:6379/1",
wantType: PubsubTypeRedis,
wantConn: "redis://10.0.0.1:6379/1",
},
{
name: "memory ignores the shared [redis] section",
ini: "[redis]\nCONN_STR = redis://127.0.0.1:6379/0\n[websocket]\nPUBSUB_TYPE = memory",
wantType: PubsubTypeMemory,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer test.MockVariableValue(&Websocket)()
defer test.MockVariableValue(&Redis)()
cfg, err := NewConfigProviderFromData(tc.ini)
assert.NoError(t, err)
loadRedisFrom(cfg)
loadWebsocketFrom(cfg)
assert.Equal(t, tc.wantType, Websocket.PubsubType)
assert.Equal(t, tc.wantConn, Websocket.PubsubConnStr)
})
}
}
+3 -4
View File
@@ -116,10 +116,9 @@ func newFuncMapWebPage() template.FuncMap {
},
"NotificationSettings": func() map[string]any {
return map[string]any{
"MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
"TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
"MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond),
"EventSourceUpdateTime": int(setting.UI.Notification.EventSourceUpdateTime / time.Millisecond),
"MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond),
"TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
"MaxTimeout": int(setting.UI.Notification.MaxTimeout / time.Millisecond),
}
},
"MermaidMaxSourceCharacters": func() int {
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package test
import (
"net"
"os"
"os/exec"
"time"
)
const (
testRedisHost = "127.0.0.1"
testRedisPort = "6379"
testRedisAddr = testRedisHost + ":" + testRedisPort
testRedisConnStr = "redis://" + testRedisAddr + "/0"
)
// waitRedisReady reports whether redis accepts connections within dur. Redis
// binds its listener last during startup, so a successful dial means it can
// serve. A plain dial, not a redis PING: the client retries its pool on a
// refused connect, which makes the "is one already running" probe take ~1s.
func waitRedisReady(dur time.Duration) bool {
for start := time.Now(); ; time.Sleep(50 * time.Millisecond) {
conn, err := net.DialTimeout("tcp", testRedisAddr, time.Second)
if err == nil {
_ = conn.Close()
return true
}
if time.Since(start) > dur {
return false
}
}
}
func redisServerCmd(t TestingT) *exec.Cmd {
redisServerProg, err := exec.LookPath("redis-server")
if err != nil {
return nil
}
return &exec.Cmd{
Path: redisServerProg,
Args: []string{redisServerProg, "--bind", testRedisHost, "--port", testRedisPort},
Dir: t.TempDir(),
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
}
// PrepareTestRedis returns a connection string to a running redis, starting one
// for the duration of the test if the port is free.
func PrepareTestRedis(t TestingT) string {
if waitRedisReady(0) {
return testRedisConnStr
}
redisServer := redisServerCmd(t)
if redisServer == nil {
if AllowSkipExternalService() {
t.Skipf("redis-server command not found, skipped")
} else {
t.Fatalf("no redis server or command, but skipping is not allowed")
}
return testRedisConnStr
}
if err := redisServer.Start(); err != nil {
t.Fatalf("failed to start redis-server: %v", err)
}
t.Cleanup(func() {
_ = redisServer.Process.Signal(os.Interrupt)
_ = redisServer.Wait()
})
if !waitRedisReady(5 * time.Second) {
t.Fatalf("failed to start redis-server")
}
return testRedisConnStr
}
+4 -2
View File
@@ -157,11 +157,13 @@ var AllowSkipExternalService = sync.OnceValue(func() bool {
})
type TestingT interface {
Cleanup(func())
Context() context.Context
Helper()
Skipf(format string, args ...any)
Errorf(format string, args ...any)
Fatalf(format string, args ...any)
Helper()
Skipf(format string, args ...any)
TempDir() string
}
func ExternalServiceHTTP(t TestingT, envVarName, def string) string {
+1 -1
View File
@@ -41,7 +41,7 @@ func (manager *loggerRequestManager) startSlowQueryDetector(threshold time.Durat
ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: SlowQueryDetector", process.SystemProcessType, true)
defer finished()
// This go-routine checks all active requests every second.
// If a request has been running for a long time (eg: /user/events), we also print a log with "still-executing" message
// If a request has been running for a long time, we also print a log with "still-executing" message
// After the "still-executing" log is printed, the record will be removed from the map to prevent from duplicated logs in future
// We do not care about accurate duration here. It just does the check periodically, 0.5s or 1.5s are all OK.
t := time.NewTicker(time.Second)