detached cb6aa35

Merge pull request #171 from bnema/fix/oauth-popup-gtk-thread-safety

fix oauth popup auto-close stability in GTK flow
commit
cb6aa352cd4ba9ea001e870c9597c5ce3ef97e3b
author
bnema · · 2026-02-17 05:36 UTC
committer
GitHub · · 2026-02-17 05:36 UTC
merge of
f71271c + dbb6e05 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +60 -11
M internal/ui/coordinator/content.go +26 -10
@@ -1799,6 +1799,7 @@ func (c *ContentCoordinator) setupOAuthAutoClose(
17991799 var cancelSafetyTimerOnce sync.Once
18001800 var requestCloseOnce sync.Once
18011801 const oauthSafetyTimeout = 30 * time.Second
1802+ const oauthCloseDelay = 500 * time.Millisecond
18021803
18031804 startSafetyTimer := func() {
18041805 safetyTimerMu.Lock()
@@ -1807,10 +1808,22 @@ func (c *ContentCoordinator) setupOAuthAutoClose(
18071808 safetyTimer.Stop()
18081809 }
18091810 safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
1810- if wv != nil && !wv.IsDestroyed() {
1811- log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
1812- wv.Close()
1813- }
1811+ cb := glib.SourceFunc(func(_ uintptr) bool {
1812+ if wv != nil && !wv.IsDestroyed() {
1813+ uri := wv.URI()
1814+ if shouldForceCloseOnSafetyTimeout(uri) {
1815+ log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
1816+ wv.Close()
1817+ return false
1818+ }
1819+ log.Debug().
1820+ Str("pane", string(paneID)).
1821+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
1822+ Msg("oauth safety timeout reached during active auth flow, skipping forced close")
1823+ }
1824+ return false
1825+ })
1826+ glib.IdleAdd(&cb, 0)
18141827 })
18151828 }
18161829
@@ -1833,12 +1846,15 @@ func (c *ContentCoordinator) setupOAuthAutoClose(
18331846 Str("reason", reason).
18341847 Msg("oauth callback detected, closing")
18351848 requestCloseOnce.Do(func() {
1836- go func() {
1837- time.Sleep(500 * time.Millisecond)
1838- if wv != nil && !wv.IsDestroyed() {
1839- wv.Close()
1840- }
1841- }()
1849+ time.AfterFunc(oauthCloseDelay, func() {
1850+ cb := glib.SourceFunc(func(_ uintptr) bool {
1851+ if wv != nil && !wv.IsDestroyed() {
1852+ wv.Close()
1853+ }
1854+ return false
1855+ })
1856+ glib.IdleAdd(&cb, 0)
1857+ })
18421858 })
18431859 }
18441860
@@ -1799,6 +1799,7 @@ func (c *ContentCoordinator) setupOAuthAutoClose(
1799 var cancelSafetyTimerOnce sync.Once1799 var cancelSafetyTimerOnce sync.Once
1800 var requestCloseOnce sync.Once1800 var requestCloseOnce sync.Once
1801 const oauthSafetyTimeout = 30 * time.Second1801 const oauthSafetyTimeout = 30 * time.Second
1802+ const oauthCloseDelay = 500 * time.Millisecond
1802 1803
1803 startSafetyTimer := func() {1804 startSafetyTimer := func() {
1804 safetyTimerMu.Lock()1805 safetyTimerMu.Lock()
@@ -1807,10 +1808,22 @@ func (c *ContentCoordinator) setupOAuthAutoClose(
1807 safetyTimer.Stop()1808 safetyTimer.Stop()
1808 }1809 }
1809 safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {1810 safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
1810- if wv != nil && !wv.IsDestroyed() {1811+ cb := glib.SourceFunc(func(_ uintptr) bool {
1811- log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")1812+ if wv != nil && !wv.IsDestroyed() {
1812- wv.Close()1813+ uri := wv.URI()
1813- }1814+ if shouldForceCloseOnSafetyTimeout(uri) {
1815+ log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
1816+ wv.Close()
1817+ return false
1818+ }
1819+ log.Debug().
1820+ Str("pane", string(paneID)).
1821+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
1822+ Msg("oauth safety timeout reached during active auth flow, skipping forced close")
1823+ }
1824+ return false
1825+ })
1826+ glib.IdleAdd(&cb, 0)
1814 })1827 })
1815 }1828 }
1816 1829
@@ -1833,12 +1846,15 @@ func (c *ContentCoordinator) setupOAuthAutoClose(
1833 Str("reason", reason).1846 Str("reason", reason).
1834 Msg("oauth callback detected, closing")1847 Msg("oauth callback detected, closing")
1835 requestCloseOnce.Do(func() {1848 requestCloseOnce.Do(func() {
1836- go func() {1849+ time.AfterFunc(oauthCloseDelay, func() {
1837- time.Sleep(500 * time.Millisecond)1850+ cb := glib.SourceFunc(func(_ uintptr) bool {
1838- if wv != nil && !wv.IsDestroyed() {1851+ if wv != nil && !wv.IsDestroyed() {
1839- wv.Close()1852+ wv.Close()
1840- }1853+ }
1841- }()1854+ return false
1855+ })
1856+ glib.IdleAdd(&cb, 0)
1857+ })
1842 })1858 })
1843 }1859 }
1844 1860
M internal/ui/coordinator/oauth.go +10 -1
@@ -48,7 +48,6 @@ var oauthRequestPatterns = []string{
4848 "client_id=",
4949 "redirect_uri=",
5050 "scope=",
51- "state=",
5251 "nonce=",
5352 }
5453
@@ -106,6 +105,16 @@ func ShouldAutoClose(url string) bool {
106105 return IsOAuthCallback(url)
107106 }
108107
108+// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
109+// when the OAuth safety timeout is reached.
110+//
111+// Safety-timeout force close is disabled for stability. OAuth popups close on
112+// callback detection or manual user action.
113+func shouldForceCloseOnSafetyTimeout(url string) bool {
114+ _ = url
115+ return false
116+}
117+
109118 // IsOAuthSuccess checks if the callback indicates successful authentication.
110119 func IsOAuthSuccess(url string) bool {
111120 if url == "" {
@@ -48,7 +48,6 @@ var oauthRequestPatterns = []string{
48 "client_id=",48 "client_id=",
49 "redirect_uri=",49 "redirect_uri=",
50 "scope=",50 "scope=",
51- "state=",
52 "nonce=",51 "nonce=",
53 }52 }
54 53
@@ -106,6 +105,16 @@ func ShouldAutoClose(url string) bool {
106 return IsOAuthCallback(url)105 return IsOAuthCallback(url)
107 }106 }
108 107
108+// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
109+// when the OAuth safety timeout is reached.
110+//
111+// Safety-timeout force close is disabled for stability. OAuth popups close on
112+// callback detection or manual user action.
113+func shouldForceCloseOnSafetyTimeout(url string) bool {
114+ _ = url
115+ return false
116+}
117+
109 // IsOAuthSuccess checks if the callback indicates successful authentication.118 // IsOAuthSuccess checks if the callback indicates successful authentication.
110 func IsOAuthSuccess(url string) bool {119 func IsOAuthSuccess(url string) bool {
111 if url == "" {120 if url == "" {
M internal/ui/coordinator/oauth_test.go +24 -0
@@ -96,6 +96,30 @@ func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) {
9696 assert.Nil(t, c.popupRefresh[parentPaneID], "oauth errors should not refresh parent pane")
9797 }
9898
99+func TestIsOAuthURL_DoesNotTreatGenericStateParamAsOAuth(t *testing.T) {
100+ url := "https://github.com/apps/linear/installations/new?state=Ar4EJk1ao3eyEDgSbYOtG8Cr4"
101+
102+ assert.False(t, IsOAuthURL(url))
103+}
104+
105+func TestIsOAuthURL_DetectsOAuthAuthorizeRequest(t *testing.T) {
106+ url := "https://auth.openai.com/oauth/authorize?response_type=code&client_id=app_123&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email+offline_access&state=abc"
107+
108+ assert.True(t, IsOAuthURL(url))
109+}
110+
111+func TestShouldForceCloseOnSafetyTimeout(t *testing.T) {
112+ t.Run("does not force close during in-progress auth challenge", func(t *testing.T) {
113+ url := "https://accounts.google.com/v3/signin/challenge/dp?TL=AHU8sQabc"
114+ assert.False(t, shouldForceCloseOnSafetyTimeout(url))
115+ })
116+
117+ t.Run("does not force close when callback is reached", func(t *testing.T) {
118+ url := "http://localhost:1455/auth/callback?code=abc123&state=xyz"
119+ assert.False(t, shouldForceCloseOnSafetyTimeout(url))
120+ })
121+}
122+
99123 func waitFor(t *testing.T, timeout time.Duration, condition func() bool) {
100124 t.Helper()
101125
@@ -96,6 +96,30 @@ func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) {
96 assert.Nil(t, c.popupRefresh[parentPaneID], "oauth errors should not refresh parent pane")96 assert.Nil(t, c.popupRefresh[parentPaneID], "oauth errors should not refresh parent pane")
97 }97 }
98 98
99+func TestIsOAuthURL_DoesNotTreatGenericStateParamAsOAuth(t *testing.T) {
100+ url := "https://github.com/apps/linear/installations/new?state=Ar4EJk1ao3eyEDgSbYOtG8Cr4"
101+
102+ assert.False(t, IsOAuthURL(url))
103+}
104+
105+func TestIsOAuthURL_DetectsOAuthAuthorizeRequest(t *testing.T) {
106+ url := "https://auth.openai.com/oauth/authorize?response_type=code&client_id=app_123&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email+offline_access&state=abc"
107+
108+ assert.True(t, IsOAuthURL(url))
109+}
110+
111+func TestShouldForceCloseOnSafetyTimeout(t *testing.T) {
112+ t.Run("does not force close during in-progress auth challenge", func(t *testing.T) {
113+ url := "https://accounts.google.com/v3/signin/challenge/dp?TL=AHU8sQabc"
114+ assert.False(t, shouldForceCloseOnSafetyTimeout(url))
115+ })
116+
117+ t.Run("does not force close when callback is reached", func(t *testing.T) {
118+ url := "http://localhost:1455/auth/callback?code=abc123&state=xyz"
119+ assert.False(t, shouldForceCloseOnSafetyTimeout(url))
120+ })
121+}
122+
99 func waitFor(t *testing.T, timeout time.Duration, condition func() bool) {123 func waitFor(t *testing.T, timeout time.Duration, condition func() bool) {
100 t.Helper()124 t.Helper()
101 125

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close