From 72f3a0d1723aa2a488423ac488403d05ecd522f6 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 24 Apr 2026 12:08:07 +0200 Subject: [PATCH 1/4] Retry logout event delivery briefly for racing client connections When a user has multiple tabs open and one triggers logout while another is still establishing its SSE connection, the previous SendMessageBlocking would silently drop the event because no messenger was registered yet. Add SendMessageBlockingWithRetry which polls up to a deadline for a messenger to appear, and use it from SignOut with a 500ms budget. Co-Authored-By: Claude (Opus 4.7) --- modules/eventsource/manager.go | 21 +++++++++++++++++++++ routers/web/auth/auth.go | 7 +++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/modules/eventsource/manager.go b/modules/eventsource/manager.go index 7ed2a829038f3..5279194938b6c 100644 --- a/modules/eventsource/manager.go +++ b/modules/eventsource/manager.go @@ -5,6 +5,7 @@ package eventsource import ( "sync" + "time" ) // Manager manages the eventsource Messengers @@ -87,3 +88,23 @@ func (m *Manager) SendMessageBlocking(uid int64, message *Event) { messenger.SendMessageBlocking(message) } } + +// SendMessageBlockingWithRetry sends a message, retrying for up to retry duration +// if no messenger is registered yet. Useful for events like logout where a client +// tab may still be establishing its SSE connection when another tab triggers the event. +func (m *Manager) SendMessageBlockingWithRetry(uid int64, message *Event, retry time.Duration) { + deadline := time.Now().Add(retry) + for { + m.mutex.Lock() + messenger, ok := m.messengers[uid] + m.mutex.Unlock() + if ok { + messenger.SendMessageBlocking(message) + return + } + if time.Now().After(deadline) { + return + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 1baa022521770..255f804009746 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "strings" + "time" "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -471,10 +472,12 @@ 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{ + // Retry briefly in case another tab is still establishing its SSE connection + // when this logout fires — otherwise the event would be silently dropped. + eventsource.GetManager().SendMessageBlockingWithRetry(ctx.Doer.ID, &eventsource.Event{ Name: "logout", Data: ctx.Session.ID(), - }) + }, 500*time.Millisecond) } // prepare the sign-out URL before destroying the session From 6cee3bdde823259fe1676b24168cf34e60a1e2b0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 24 Apr 2026 12:40:46 +0200 Subject: [PATCH 2/4] Revert "Retry logout event delivery briefly for racing client connections" This reverts commit 72f3a0d1723aa2a488423ac488403d05ecd522f6. --- modules/eventsource/manager.go | 21 --------------------- routers/web/auth/auth.go | 7 ++----- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/modules/eventsource/manager.go b/modules/eventsource/manager.go index 5279194938b6c..7ed2a829038f3 100644 --- a/modules/eventsource/manager.go +++ b/modules/eventsource/manager.go @@ -5,7 +5,6 @@ package eventsource import ( "sync" - "time" ) // Manager manages the eventsource Messengers @@ -88,23 +87,3 @@ func (m *Manager) SendMessageBlocking(uid int64, message *Event) { messenger.SendMessageBlocking(message) } } - -// SendMessageBlockingWithRetry sends a message, retrying for up to retry duration -// if no messenger is registered yet. Useful for events like logout where a client -// tab may still be establishing its SSE connection when another tab triggers the event. -func (m *Manager) SendMessageBlockingWithRetry(uid int64, message *Event, retry time.Duration) { - deadline := time.Now().Add(retry) - for { - m.mutex.Lock() - messenger, ok := m.messengers[uid] - m.mutex.Unlock() - if ok { - messenger.SendMessageBlocking(message) - return - } - if time.Now().After(deadline) { - return - } - time.Sleep(20 * time.Millisecond) - } -} diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 255f804009746..1baa022521770 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -11,7 +11,6 @@ import ( "net/http" "net/url" "strings" - "time" "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -472,12 +471,10 @@ func HandleSignOut(ctx *context.Context) { // SignOut sign out from login status func SignOut(ctx *context.Context) { if ctx.Doer != nil { - // Retry briefly in case another tab is still establishing its SSE connection - // when this logout fires — otherwise the event would be silently dropped. - eventsource.GetManager().SendMessageBlockingWithRetry(ctx.Doer.ID, &eventsource.Event{ + eventsource.GetManager().SendMessageBlocking(ctx.Doer.ID, &eventsource.Event{ Name: "logout", Data: ctx.Session.ID(), - }, 500*time.Millisecond) + }) } // prepare the sign-out URL before destroying the session From 3f7c821f2eb3e73356d5bb0cbe0b73bfda3e58cb Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 24 Apr 2026 12:40:56 +0200 Subject: [PATCH 3/4] Delay logout trigger to avoid racing SSE connection in e2e test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give page2's SharedWorker time to register its SSE connection on the server before page1 triggers logout — otherwise the event can be silently dropped when no messenger is registered yet for the user. Co-Authored-By: Claude (Opus 4.7) --- tests/e2e/events.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index d033c7b94f320..35f4a14fda4b4 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -66,6 +66,11 @@ test.describe('events', () => { // Verify page2 is logged in await expect(page2.getByRole('link', {name: 'Sign In'})).toBeHidden(); + // Give page2's SharedWorker time to register its SSE connection on the + // server — otherwise the logout event can race the connection and be + // silently dropped. See https://github.com/go-gitea/gitea/pull/37403 + await page2.waitForTimeout(500); // eslint-disable-line playwright/no-wait-for-timeout + // Logout from page1 — this sends a logout event to all tabs await page1.goto('/user/logout'); From 015219f6d32462d6a5e6aa664c00a0ed2bf75fd0 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 24 Apr 2026 18:48:58 +0800 Subject: [PATCH 4/4] Apply suggestion from @wxiaoguang Signed-off-by: wxiaoguang --- tests/e2e/events.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index 35f4a14fda4b4..4c1c525cf54e9 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -69,6 +69,8 @@ test.describe('events', () => { // Give page2's SharedWorker time to register its SSE connection on the // server — otherwise the logout event can race the connection and be // silently dropped. See https://github.com/go-gitea/gitea/pull/37403 + // In the future, we can set an attribute to HTML page when the connection is established, + // then here we can just wait for that attribute (it should also work for the planned WebSocket SharedWorker) await page2.waitForTimeout(500); // eslint-disable-line playwright/no-wait-for-timeout // Logout from page1 — this sends a logout event to all tabs