enhance(websocket): improve client synchronization (#38847) (#40340)

Backport of https://github.com/go-gitea/gitea/pull/38847, which fixes
the stopwatch push flake in
https://github.com/go-gitea/gitea/actions/runs/43200377380/job/131122930118.

The stopwatch e2e test keeps its v28 flow, since main reworked it in a
separate change.

Assisted-by: Claude Code:claude-opus-5-5
This commit is contained in:
silverwind authored and GitHub committed 2026-11-12 14:00:15 +00:00
1 parent 78698abee0
commit b60da6d876
20 files changed
+267 -247

No files matched your search

+43 -37
View File
@@ -9,7 +9,6 @@ import (
"time"
"gitea.dev/modules/graceful"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/services/context"
"gitea.dev/services/pubsub"
@@ -30,23 +29,6 @@ const (
closeCodeUnauthenticated gitea_ws.StatusCode = 3000
)
// filterLogout forwards a session-free logout only to the targeted connection
// (its own session, or every session when SessionID is empty) and drops it for
// the rest. Non-logout messages pass through untouched.
func filterLogout(eventType string, eventDataBytes []byte, connSessionID string) []byte {
if eventType != websocket_service.EventLogout {
return eventDataBytes
}
var lm websocket_service.UserEventMessage[websocket_service.LogoutEventData]
if err := json.Unmarshal(eventDataBytes, &lm); err != nil {
return eventDataBytes
}
if lm.EventData.SessionID == "" || lm.EventData.SessionID == connSessionID {
return []byte(`{"eventType":"logout"}`)
}
return nil
}
func Serve(ctx *context.Context) {
// Answer plain GETs (health checks, crawlers) here; letting Accept reject them
// would log an error per request. Same reply it would have sent.
@@ -57,6 +39,14 @@ func Serve(ctx *context.Context) {
return
}
if !ctx.IsSigned {
rejectUnauthenticated(ctx)
return
}
ch, cancel := pubsub.DefaultBroker.Subscribe(pubsub.UserTopic(ctx.Doer.ID)) // before the snapshot, so no change falls between them
defer cancel()
conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, nil)
if err != nil {
log.Error("websocket: accept failed: %v", err)
@@ -64,14 +54,7 @@ func Serve(ctx *context.Context) {
}
defer conn.CloseNow() //nolint:errcheck // best-effort close
if !ctx.IsSigned {
_ = conn.Close(closeCodeUnauthenticated, "unauthenticated")
return
}
sessionID := ctx.Session.ID()
ch, cancel := pubsub.DefaultBroker.Subscribe(pubsub.UserTopic(ctx.Doer.ID))
defer cancel()
// Ping requires a concurrent reader to observe the pong frame; CloseRead
// spawns one and cancels its context when the peer goes away.
@@ -80,6 +63,30 @@ func Serve(ctx *context.Context) {
pingTicker := time.NewTicker(pingInterval)
defer pingTicker.Stop()
send := func(brokerPayload []byte) error {
eventDataBytes := websocket_service.EventForSession(brokerPayload, sessionID)
if eventDataBytes == nil {
return nil
}
writeCtx, cancelWrite := gocontext.WithTimeout(wsCtx, writeTimeout) // a stalled peer must not starve the ping ticker
defer cancelWrite()
err := conn.Write(writeCtx, gitea_ws.MessageText, eventDataBytes)
if err != nil {
log.Trace("websocket: write failed: %v", err)
}
return err
}
var backlog [][]byte // published before the snapshot read, so it must not follow the snapshot
for len(ch) > 0 {
backlog = append(backlog, <-ch)
}
for _, brokerPayload := range append(backlog, websocket_service.UserData(wsCtx, ctx.Doer)...) {
if err := send(brokerPayload); err != nil {
return
}
}
for {
select {
case <-wsCtx.Done():
@@ -98,20 +105,19 @@ func Serve(ctx *context.Context) {
if !ok {
return
}
eventType, eventDataBytes := websocket_service.ExtractUserEventMessage(brokerPayload)
eventDataBytes = filterLogout(eventType, eventDataBytes, sessionID)
if eventDataBytes == nil {
continue
}
// Bound the write so a stalled/slow peer can't block this goroutine
// indefinitely and starve the ping ticker.
writeCtx, cancelWrite := gocontext.WithTimeout(wsCtx, writeTimeout)
err := conn.Write(writeCtx, gitea_ws.MessageText, eventDataBytes)
cancelWrite()
if err != nil {
log.Trace("websocket: write failed: %v", err)
if err := send(brokerPayload); err != nil {
return
}
}
}
}
// rejectUnauthenticated upgrades only to close, so clients stop reconnecting.
func rejectUnauthenticated(ctx *context.Context) {
conn, err := gitea_ws.Accept(ctx.Resp, ctx.Req, nil)
if err != nil {
log.Error("websocket: accept failed: %v", err)
return
}
_ = conn.Close(closeCodeUnauthenticated, "unauthenticated")
}
-60
View File
@@ -1,60 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package websocket
import (
"testing"
"gitea.dev/services/websocket"
"github.com/stretchr/testify/assert"
)
func TestFilterLogout(t *testing.T) {
cases := []struct {
name string
brokerMsg []byte
connSessID string
want []byte // expected payload forwarded to the client
}{
{
name: "originating session gets a session-free logout",
brokerMsg: websocket.MakeUserEventMessage("logout", websocket.LogoutEventData{SessionID: "sess-A"}),
connSessID: "sess-A",
want: []byte(`{"eventType":"logout"}`),
},
{
name: "other session is dropped",
brokerMsg: websocket.MakeUserEventMessage("logout", websocket.LogoutEventData{SessionID: "sess-A"}),
connSessID: "sess-B",
want: nil,
},
{
name: "empty sessionID reaches every session",
brokerMsg: websocket.MakeUserEventMessage("logout", nil),
connSessID: "sess-A",
want: []byte(`{"eventType":"logout"}`),
},
{
name: "non-logout message passes through unchanged",
brokerMsg: websocket.MakeUserEventMessage("other", map[string]any{"k": "v"}),
connSessID: "sess-A",
want: []byte(`{"eventType":"other","eventData":{"k":"v"}}`),
},
{
name: "malformed JSON with logout marker passes through unchanged",
brokerMsg: []byte("any type\nany data"),
connSessID: "sess-A",
want: []byte("any data"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
eventType, eventData := websocket.ExtractUserEventMessage(tc.brokerMsg)
out := filterLogout(eventType, eventData, tc.connSessID)
assert.Equal(t, tc.want, out)
})
}
}
-22
View File
@@ -29,7 +29,6 @@ func testBrokerBasic(t *testing.T, newBroker newBrokerFunc, recvTimeout time.Dur
b := newBroker(t)
ch, cancel := b.Subscribe(t.Name())
defer cancel()
waitSubscribed(t, b, t.Name())
b.Publish(t.Name(), []byte("hello"))
assert.Equal(t, []byte("hello"), recvWithin(t, ch, recvTimeout))
@@ -44,7 +43,6 @@ func testBrokerBasic(t *testing.T, newBroker newBrokerFunc, recvTimeout time.Dur
defer cancel()
channels[i] = ch
}
waitSubscribed(t, b, t.Name()) // all local subscribers share one Redis SUBSCRIBE
b.Publish(t.Name(), []byte("broadcast"))
for i, ch := range channels {
@@ -59,8 +57,6 @@ func testBrokerBasic(t *testing.T, newBroker newBrokerFunc, recvTimeout time.Dur
defer cancelA()
chB, cancelB := b.Subscribe(topicB)
defer cancelB()
waitSubscribed(t, b, topicA)
waitSubscribed(t, b, topicB)
b.Publish(topicA, []byte("only-a"))
assert.Equal(t, []byte("only-a"), recvWithin(t, chA, recvTimeout))
@@ -100,7 +96,6 @@ func testBrokerBasic(t *testing.T, newBroker newBrokerFunc, recvTimeout time.Dur
defer cancelSlow()
fast, cancelFast := b.Subscribe(t.Name())
defer cancelFast()
waitSubscribed(t, b, t.Name())
// Drain fast concurrently so it keeps up while slow's buffer fills.
got := make(chan struct{}, 1)
@@ -147,23 +142,6 @@ func testBrokerBasic(t *testing.T, newBroker newBrokerFunc, recvTimeout time.Dur
})
}
// waitSubscribed blocks until Redis registers the subscription, since
// RedisBroker.Subscribe returns before the server acks SUBSCRIBE and a publish
// right after it would race it. Topics are per-scenario, so any subscriber is
// this one. No-op for MemoryBroker, which registers synchronously.
func waitSubscribed(t *testing.T, b Broker, topic string) {
t.Helper()
rb, ok := b.(*RedisBroker)
if !ok {
return
}
channel := redisChannelForTopic(topic)
require.Eventually(t, func() bool {
res, err := rb.client.PubSubNumSub(t.Context(), channel).Result()
return err == nil && res[channel] > 0
}, 5*time.Second, 10*time.Millisecond, "redis did not register a subscriber on %q", topic)
}
// recvWithin returns the next message or fails if none arrives before timeout.
func recvWithin(t *testing.T, ch <-chan []byte, timeout time.Duration) []byte {
t.Helper()
+12 -8
View File
@@ -17,10 +17,11 @@ import (
)
const (
redisPingTimeout = 3 * time.Second
redisPingRetries = 10
redisPingRetryDelay = time.Second
redisPublishTimeout = 2 * time.Second
redisPingTimeout = 3 * time.Second
redisPingRetries = 10
redisPingRetryDelay = time.Second
redisPublishTimeout = 2 * time.Second
redisSubscribeTimeout = 2 * time.Second
)
// RedisBroker fans out across processes via Redis pub/sub. Each topic is
@@ -94,12 +95,15 @@ func (b *RedisBroker) Subscribe(topic string) (<-chan []byte, func()) {
// other Subscribe/cancel calls aren't blocked on the network round-trip.
// graceful.ShutdownContext so the reader loop dies cleanly on Gitea
// shutdown even if every local subscriber has already cancelled.
// readLoop consumes the SUBSCRIBE ack; don't wait for it here, that would put
// a Redis round-trip in the WebSocket handshake to close a sub-millisecond
// window in which a publish is missed - harmless, since a client receives
// nothing at all until its handshake completes.
ctx, cancelCtx := context.WithCancel(graceful.GetManager().ShutdownContext())
ps := b.client.Subscribe(ctx, redisChannelForTopic(topic))
if _, err := ps.ReceiveTimeout(ctx, redisSubscribeTimeout); err != nil { // the ack, so state read afterwards misses no publish
log.Error("pubsub redis: subscribe to %q: %v", topic, err)
cancelCtx()
_ = ps.Close()
sub.close()
return sub.ch, func() {}
}
b.mu.Lock()
if existing, exists := b.topics[topic]; exists {
// Another goroutine won the create race; merge into theirs and discard ours.
-1
View File
@@ -59,7 +59,6 @@ func TestRedisBroker(t *testing.T) {
ch, cancel := subscriber.Subscribe(t.Name())
defer cancel()
waitSubscribed(t, subscriber, t.Name())
publisher.Publish(t.Name(), []byte("cross-process"))
+4 -1
View File
@@ -55,7 +55,10 @@ func handler(items ...issueNotificationOpts) []issueNotificationOpts {
for _, opts := range items {
notifiedIDs, err := activities_model.CreateOrUpdateIssueNotifications(ctx, opts.IssueID, opts.CommentID, opts.NotificationAuthorID, opts.ReceiverID)
if err != nil {
log.Error("Was unable to create issue notification: %v", err)
// the issue can be gone by now, e.g. its repository was deleted
if !issues_model.IsErrIssueNotExist(err) {
log.Error("Unable to create issue notification: %v", err)
}
continue
}
for _, userID := range notifiedIDs {
-53
View File
@@ -1,53 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package websocket
import (
"bytes"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
"gitea.dev/services/pubsub"
)
// Wire contract with web_src/js/user-events.sharedworker.ts — keep in sync.
const (
EventNotificationCount = "notification-count"
EventStopwatches = "stopwatches"
EventLogout = "logout"
)
type UserEventMessage[T any] struct {
EventType string `json:"eventType"`
EventData T `json:"eventData"`
}
func publishUserEvent(userID int64, eventType string, eventData any) {
if pubsub.DefaultBroker == nil {
return
}
b := MakeUserEventMessage(eventType, eventData)
if b == nil {
return
}
pubsub.DefaultBroker.Publish(pubsub.UserTopic(userID), b)
}
func MakeUserEventMessage(eventType string, eventData any) []byte {
buf := &bytes.Buffer{}
buf.WriteString(eventType)
buf.WriteByte('\n')
err := json.MarshalWriteRelaxed(buf, &UserEventMessage[any]{EventType: eventType, EventData: eventData})
payloadBytes := bytes.TrimSuffix(buf.Bytes(), []byte("\n")) // json v1 adds extra "\n" but we don't want it
if err != nil {
setting.PanicInDevOrTesting("websocket: marshal event: %v", err)
return nil
}
return payloadBytes
}
func ExtractUserEventMessage(b []byte) (string, []byte) {
eventType, eventDataBytes, _ := bytes.Cut(b, []byte("\n"))
return string(eventType), eventDataBytes
}
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package websocket
const eventLogout = "logout" // keep in sync with web_src/js/types.ts
func PublishLogout(userID int64, sessionID string) {
publishUserEvent(userID, func() []byte { return makeEventMessage(eventLogout, sessionID, nil) })
}
-12
View File
@@ -1,12 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package websocket
type LogoutEventData struct {
SessionID string `json:"sessionID,omitempty"`
}
func PublishLogout(userID int64, sessionID string) {
publishUserEvent(userID, EventLogout, LogoutEventData{SessionID: sessionID})
}
@@ -9,31 +9,24 @@ import (
activities_model "gitea.dev/models/activities"
"gitea.dev/models/db"
"gitea.dev/modules/log"
notify_service "gitea.dev/services/notify"
"gitea.dev/services/pubsub"
)
type notificationCountEventData struct {
Count int64 `json:"count"`
}
type wsNotifier struct {
notify_service.NullNotifier
}
var _ notify_service.Notifier = &wsNotifier{}
const eventNotificationCount = "notification-count" // keep in sync with web_src/js/types.ts
func (n *wsNotifier) NotificationCountChange(ctx context.Context, userID int64) {
if !pubsub.DefaultBroker.HasTopicSubscribers(pubsub.UserTopic(userID)) {
return
}
publishUserEvent(userID, func() []byte { return notificationCountEvent(ctx, userID) })
}
func notificationCountEvent(ctx context.Context, userID int64) []byte {
count, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
UserID: userID,
Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread},
})
if err != nil {
log.Error("websocket: count notifications for user %d: %v", userID, err)
return
return nil
}
publishUserEvent(userID, EventNotificationCount, notificationCountEventData{Count: count})
return makeEventMessage(eventNotificationCount, "", struct {
Count int64 `json:"count"`
}{count})
}
-18
View File
@@ -1,18 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package websocket
import (
notify_service "gitea.dev/services/notify"
"gitea.dev/services/pubsub"
)
func Init() error {
// the pubsub broker must be ready before the notifier starts publishing to it
if err := pubsub.Init(); err != nil {
return err
}
notify_service.RegisterNotifier(&wsNotifier{})
return nil
}
@@ -10,28 +10,32 @@ import (
issues_model "gitea.dev/models/issues"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/convert"
"gitea.dev/services/pubsub"
)
func (n *wsNotifier) StopwatchChanged(ctx context.Context, user *user_model.User) {
if !pubsub.DefaultBroker.HasTopicSubscribers(pubsub.UserTopic(user.ID)) {
return
}
const eventStopwatches = "stopwatches" // keep in sync with web_src/js/types.ts
func (n *wsNotifier) StopwatchChanged(ctx context.Context, user *user_model.User) {
publishUserEvent(user.ID, func() []byte { return stopwatchesEvent(ctx, user) })
}
func stopwatchesEvent(ctx context.Context, user *user_model.User) []byte {
if !setting.Service.EnableTimetracking {
return nil
}
sws, err := issues_model.GetUserStopwatches(ctx, user.ID, db.ListOptions{})
if err != nil {
log.Error("websocket: GetUserStopwatches %d: %v", user.ID, err)
return
return nil
}
apiStopWatches, err := convert.ToStopWatches(ctx, user, sws)
if err != nil {
if !issues_model.IsErrIssueNotExist(err) {
log.Error("websocket: ToStopWatches: %v", err)
}
return
return nil
}
publishUserEvent(user.ID, EventStopwatches, util.SliceNilAsEmpty(apiStopWatches))
return makeEventMessage(eventStopwatches, "", util.SliceNilAsEmpty(apiStopWatches))
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package websocket
import (
"bytes"
"context"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
notify_service "gitea.dev/services/notify"
"gitea.dev/services/pubsub"
)
type wsNotifier struct {
notify_service.NullNotifier
}
var _ notify_service.Notifier = &wsNotifier{}
func Init() error {
// the pubsub broker must be ready before the notifier starts publishing to it
if err := pubsub.Init(); err != nil {
return err
}
notify_service.RegisterNotifier(&wsNotifier{})
return nil
}
func UserData(ctx context.Context, user *user_model.User) (data [][]byte) {
for _, event := range [][]byte{notificationCountEvent(ctx, user.ID), stopwatchesEvent(ctx, user)} {
if event != nil {
data = append(data, event)
}
}
return data
}
func EventForSession(brokerPayload []byte, sessionID string) []byte {
target, eventData, _ := bytes.Cut(brokerPayload, []byte("\n"))
if len(target) > 0 && string(target) != sessionID {
return nil
}
return eventData
}
// makeEventMessage prefixes the client JSON with its target session, empty for all sessions.
func makeEventMessage(eventType, targetSession string, eventData any) []byte {
buf := bytes.NewBufferString(targetSession + "\n")
err := json.MarshalWriteRelaxed(buf, &struct {
EventType string `json:"eventType"`
EventData any `json:"eventData,omitzero"`
}{eventType, eventData})
if err != nil {
setting.PanicInDevOrTesting("websocket: marshal event: %v", err)
return nil
}
return buf.Bytes()
}
func publishUserEvent(userID int64, build func() []byte) {
topic := pubsub.UserTopic(userID)
if !pubsub.DefaultBroker.HasTopicSubscribers(topic) {
return
}
if event := build(); event != nil {
pubsub.DefaultBroker.Publish(topic, event)
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package websocket
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestEventForSessionTargetsAndKeepsEmptyData(t *testing.T) {
targeted := makeEventMessage("targeted-event", "sess-A", nil)
assert.JSONEq(t, `{"eventType":"targeted-event"}`, string(EventForSession(targeted, "sess-A")))
assert.Nil(t, EventForSession(targeted, "sess-B"))
broadcast := makeEventMessage("list-event", "", []int{})
assert.JSONEq(t, `{"eventType":"list-event","eventData":[]}`, string(EventForSession(broadcast, "sess-A")))
}
+1 -1
View File
@@ -23,7 +23,7 @@ test.describe('events', () => {
await expect(page.locator('html[data-user-events-connected]')).toBeAttached();
await apiCreateIssue(request, {owner, repo: repoName, title: 'events-notif', headers: apiUserHeaders(commenter)});
await expect(badge).toBeVisible();
await expect(badge).toHaveText('1');
});
test('stopwatch appears and hides via real-time push', async ({page, request}) => {
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
activities_model "gitea.dev/models/activities"
"gitea.dev/models/unittest"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/services/pubsub"
"gitea.dev/tests"
"github.com/coder/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type staleBacklogBroker struct {
pubsub.Broker
}
func (b *staleBacklogBroker) Subscribe(topic string) (<-chan []byte, func()) {
ch, cancel := b.Broker.Subscribe(topic)
for range 9 {
b.Publish(topic, countEvent(-1))
}
return ch, cancel
}
func countEvent(count int) []byte {
return fmt.Appendf(nil, "\n"+`{"eventType":"notification-count","eventData":{"count":%d}}`, count)
}
func TestWebSocketSnapshotFollowsBacklog(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&pubsub.DefaultBroker, pubsub.Broker(&staleBacklogBroker{pubsub.NewMemoryBroker()}))()
session := loginUser(t, "user2")
server := httptest.NewServer(testWebRoutes)
defer server.Close()
header := http.Header{}
header.Set("Cookie", session.GetRawCookie(setting.SessionConfig.CookieName).String())
conn, _, err := websocket.Dial(t.Context(), strings.Replace(server.URL, "http", "ws", 1)+"/-/ws", &websocket.DialOptions{HTTPHeader: header}) //nolint:bodyclose // coder/websocket owns the body, it is nil after the upgrade
require.NoError(t, err)
defer conn.CloseNow()
const sentinelCount = -2
lastCount := 0
for {
_, payload, err := conn.Read(t.Context())
require.NoError(t, err)
var event struct {
EventType string `json:"eventType"`
EventData json.Value `json:"eventData"`
}
require.NoError(t, json.Unmarshal(payload, &event))
if event.EventType == "stopwatches" {
pubsub.DefaultBroker.Publish(pubsub.UserTopic(2), countEvent(sentinelCount))
continue
}
var data struct {
Count int `json:"count"`
}
require.NoError(t, json.Unmarshal(event.EventData, &data))
if data.Count == sentinelCount {
break
}
lastCount = data.Count
}
assert.Equal(t, unittest.GetCount(t, &activities_model.Notification{UserID: 2, Status: activities_model.NotificationStatusUnread}), lastCount)
}
+1 -2
View File
@@ -26,8 +26,7 @@ export function initNotificationCount() {
let pollerStarted = false;
onUserEvent('notification-count', (msg) => { receiveUpdateCount(msg.eventData.count) }); // no await
// On each (re)connect, reconcile the count from the server to recover any push dropped during the connect gap.
onUserEvent('worker-connected', () => { updateNotificationCount(); updateNotificationTable() }); // no await
onUserEvent('worker-attached', () => { updateNotificationCount(); updateNotificationTable() }); // no await
onUserEvent('worker-unavailable', () => {
if (pollerStarted) return;
pollerStarted = true;
+1 -2
View File
@@ -48,8 +48,7 @@ export function initStopwatch() {
let pollerStarted = false;
onUserEvent('stopwatches', (msg) => updateStopwatchData(msg.eventData));
// On each (re)connect, reconcile stopwatch state from the server to recover any push dropped during the connect gap.
onUserEvent('worker-connected', () => { updateStopwatch() }); // no await
onUserEvent('worker-attached', () => { updateStopwatch() }); // no await
onUserEvent('worker-unavailable', () => {
if (pollerStarted) return;
pollerStarted = true;
+3 -2
View File
@@ -87,7 +87,7 @@ export type StopwatchData = {
seconds: number,
};
// keep in sync with services/websocket/events.go
// keep in sync with services/websocket
export type ServerUserEventMessage =
{eventType: 'notification-count', eventData: {count: number}} |
{eventType: 'stopwatches', eventData: Array<StopwatchData>} |
@@ -97,7 +97,8 @@ export const serverUserEventTypes = ['notification-count', 'stopwatches', 'logou
export type UserEventMessage = ServerUserEventMessage |
{eventType: 'worker-unavailable'} |
{eventType: 'worker-connected'};
{eventType: 'worker-connected'} |
{eventType: 'worker-attached'}; // joined an open socket after its connect snapshot
export type UserEventType = UserEventMessage['eventType'];
+1 -3
View File
@@ -96,9 +96,6 @@ class WsSource {
this.ws.addEventListener('open', () => {
this.reconnectDelay = 1000;
this.failuresWithoutConnect = 0;
// Pushes fired while no client was subscribed (initial connect gap, or a
// reconnect window) are dropped server-side, so tell clients to reconcile
// their state from the server on every fresh connection.
this.source.notifyClientsUserEvent({eventType: 'worker-connected'});
});
@@ -191,6 +188,7 @@ const wsSourcesByUrl = new Map<string, WsSource>();
const openWs = wsSourcesByUrl.get(url);
if (openWs?.ws?.readyState === WebSocket.OPEN) {
postUserEventMessage(port, {eventType: 'worker-connected'});
postUserEventMessage(port, {eventType: 'worker-attached'});
}
return;
}