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

Keep notification counts and stopwatches in sync when tabs open or
reconnect, closing the gap behind [this
flake](https://github.com/go-gitea/gitea/actions/runs/31312525323/job/93242429394).

1. Send current notification counts and stopwatch state on each
WebSocket connection, replacing the separate state fetches tabs made on
every connect.
2. Tabs joining an already open connection fetch current state once,
since its snapshot went out before they attached.
3. Send events queued during the handshake before the snapshot, so older
events can't roll it back.
4. Wait for the Redis SUBSCRIBE acknowledgement, so the snapshot read
after subscribing can't miss an event.
This commit is contained in:
silverwind authored and GitHub committed 2026-10-29 05:18:17 +00:00
1 parent 965d7522e0
commit 7308c5fb97
20 files changed
+268 -247

No files matched your search

+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
@@ -76,8 +76,7 @@ export const initStopwatch = () => registerGlobalInitFunc('initActiveStopwatchNo
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;
}