detached 26ad11f

fix(cef): follow CEF JS dialog reset semantics and shutdown paths

Ignore the OnResetDialogState echo CEF sends after a suppressed dialog and
after every Cont so a spammer or trailing reset cannot cancel the user's
dialog. Skip dialogs on a destroyed WebView, cancel on renderer termination,
call Cont directly (CEF already posts to its UI thread) and always show
Dumber's own beforeunload text.
commit
26ad11fd2962170b24a6d638d9803a4dddcd1283
author
brice · · 2026-10-03 06:57 UTC
committer
brice · · 2026-10-03 07:50 UTC
parent
d5a6841
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +188 -29
M internal/application/port/jsdialog.go +3 -1
@@ -20,7 +20,9 @@ type JSDialogRequest struct {
2020 Type JSDialogType
2121 // Origin is the URL of the frame that requested the dialog (best effort).
2222 Origin string
23- // Message is the page-provided message (may be empty).
23+ // Message is the page-provided message (may be empty). It is empty for
24+ // JSDialogBeforeUnload: CEF always passes a fixed string, so the UI shows
25+ // its own text (like Chrome/Firefox).
2426 Message string
2527 // DefaultPrompt is the prefilled input for JSDialogPrompt.
2628 DefaultPrompt string
@@ -20,7 +20,9 @@ type JSDialogRequest struct {
20 Type JSDialogType20 Type JSDialogType
21 // Origin is the URL of the frame that requested the dialog (best effort).21 // Origin is the URL of the frame that requested the dialog (best effort).
22 Origin string22 Origin string
23- // Message is the page-provided message (may be empty).23+ // Message is the page-provided message (may be empty). It is empty for
24+ // JSDialogBeforeUnload: CEF always passes a fixed string, so the UI shows
25+ // its own text (like Chrome/Firefox).
24 Message string26 Message string
25 // DefaultPrompt is the prefilled input for JSDialogPrompt.27 // DefaultPrompt is the prefilled input for JSDialogPrompt.
26 DefaultPrompt string28 DefaultPrompt string
M internal/infrastructure/cef/jsdialog_handler.go +65 -26
@@ -20,9 +20,24 @@ import (
2020 // Anti-abuse: while a dialog is open for a WebView, further OnJsdialog requests
2121 // are suppressed (suppress_message=true) instead of being queued, so a page
2222 // cannot stack or spam dialogs.
23+//
24+// CEF reset semantics (javascript_dialog_manager.cc): OnResetDialogState is
25+// also delivered (a) right after a suppressed OnJsdialog and (b) at the end of
26+// every DialogClosed, i.e. inside/after each callback Cont. Those are not real
27+// resets and must not cancel the dialog the user is looking at, so
28+// jsDialogState carries a one-shot ignoreReset flag armed before suppressing,
29+// rejecting or continuing. Only WebView destruction and renderer termination
30+// cancel unconditionally.
2331
2432 // continueCEFJSDialog resolves a CEF dialog callback. It is a seam for tests.
25-var continueCEFJSDialog = continueCEFJSDialogOnUIThread
33+// CEF's callback implementation re-posts to the CEF UI thread by itself.
34+var continueCEFJSDialog = func(callback purecef.JsdialogCallback, ok bool, input string) {
35+ success := int32(0)
36+ if ok {
37+ success = 1
38+ }
39+ callback.Cont(success, input)
40+}
2641
2742 var _ purecef.JsdialogHandler = (*handlerSet)(nil)
2843
@@ -34,8 +49,25 @@ type jsDialogCall struct {
3449
3550 // jsDialogState tracks the single pending dialog of a WebView.
3651 type jsDialogState struct {
37- mu sync.Mutex
38- current *jsDialogCall
52+ mu sync.Mutex
53+ current *jsDialogCall
54+ ignoreReset bool // swallow the next OnResetDialogState (see file comment)
55+}
56+
57+// armIgnoreReset makes the next OnResetDialogState a no-op.
58+func (s *jsDialogState) armIgnoreReset() {
59+ s.mu.Lock()
60+ s.ignoreReset = true
61+ s.mu.Unlock()
62+}
63+
64+// consumeIgnoreReset reports (and clears) a pending ignoreReset.
65+func (s *jsDialogState) consumeIgnoreReset() bool {
66+ s.mu.Lock()
67+ defer s.mu.Unlock()
68+ was := s.ignoreReset
69+ s.ignoreReset = false
70+ return was
3971 }
4072
4173 // begin registers a new pending dialog; false if one is already open.
@@ -47,6 +79,9 @@ func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, bool)
4779 }
4880 call := &jsDialogCall{callback: cb}
4981 s.current = call
82+ // A new dialog means any reset owed by the previous one has been delivered
83+ // (the page is blocked until then); never carry a stale flag across.
84+ s.ignoreReset = false
5085 return call, true
5186 }
5287
@@ -67,6 +102,9 @@ func (s *jsDialogState) resolve(call *jsDialogCall, ok bool, input string) bool
67102 if s.current == call {
68103 s.current = nil
69104 }
105+ // Armed before Cont: CEF may deliver DialogClosed + OnResetDialogState
106+ // synchronously from inside it.
107+ s.ignoreReset = true
70108 s.mu.Unlock()
71109 continueCEFJSDialog(call.callback, ok, input)
72110 return true
@@ -146,6 +184,10 @@ func (h *handlerSet) OnJsdialog(
146184 if h == nil || h.wv == nil || callback == nil {
147185 return 0
148186 }
187+ if h.wv.destroyed.Load() {
188+ // Shutting down: the GTK loop may be blocked. Let CEF dismiss it.
189+ return 0
190+ }
149191 typ, ok := jsDialogTypeFromCEF(dialogType)
150192 if !ok {
151193 return 0
@@ -156,7 +198,9 @@ func (h *handlerSet) OnJsdialog(
156198 }
157199 call, ok := h.wv.jsDialogs.begin(callback)
158200 if !ok {
159- // A dialog is already open: suppress rather than stack/spam.
201+ // A dialog is already open: suppress rather than stack/spam. CEF will
202+ // follow up with OnResetDialogState, which must not cancel that dialog.
203+ h.wv.jsDialogs.armIgnoreReset()
160204 if suppressMessage != nil {
161205 *suppressMessage = 1
162206 }
@@ -175,13 +219,18 @@ func (h *handlerSet) OnJsdialog(
175219 // OnBeforeUnloadDialog implements purecef.JsdialogHandler.
176220 func (h *handlerSet) OnBeforeUnloadDialog(
177221 _ purecef.Browser,
178- messageText string,
222+ _ string, // CEF always passes a fixed string; the UI shows Dumber's own text.
179223 isReload int32,
180224 callback purecef.JsdialogCallback,
181225 ) bool {
182226 if h == nil || h.wv == nil || callback == nil {
183227 return false
184228 }
229+ if h.wv.destroyed.Load() {
230+ // CloseBrowser dispatches beforeunload; accept it instead of waiting
231+ // on UI that may never run. CEF accepts the unload when we return false.
232+ return false
233+ }
185234 ui := h.wv.jsDialogUICallbacks()
186235 if ui == nil || ui.OnJSDialog == nil {
187236 return false
@@ -189,24 +238,33 @@ func (h *handlerSet) OnBeforeUnloadDialog(
189238 call, ok := h.wv.jsDialogs.begin(callback)
190239 if !ok {
191240 // Another dialog is open: stay on the page rather than stacking.
241+ // The Cont below makes CEF send a reset that must not cancel it.
242+ h.wv.jsDialogs.armIgnoreReset()
192243 continueCEFJSDialog(callback, false, "")
193244 return true
194245 }
195246 h.wv.presentJSDialog(call, port.JSDialogRequest{
196247 Type: port.JSDialogBeforeUnload,
197248 Origin: h.wv.URI(),
198- Message: messageText,
199249 IsReload: isReload != 0,
200250 }, ui)
201251 return true
202252 }
203253
204254 // OnResetDialogState implements purecef.JsdialogHandler: CEF canceled pending
205-// dialogs (navigation, close). Close the UI and resolve the callback.
255+// dialogs (navigation, close). Close the UI and resolve the callback, unless
256+// this is the echo of a suppress/reject/Cont (see file comment).
257+//
258+// The UI reset (OnJSDialogReset) carries no dialog identity: it hides whatever
259+// the pane shows. That is safe because a pane shows at most one dialog and a
260+// new one is only presented after the previous call was resolved.
206261 func (h *handlerSet) OnResetDialogState(_ purecef.Browser) {
207262 if h == nil || h.wv == nil {
208263 return
209264 }
265+ if h.wv.jsDialogs.consumeIgnoreReset() {
266+ return
267+ }
210268 h.wv.cancelJSDialogs()
211269 }
212270
@@ -214,22 +272,3 @@ func (h *handlerSet) OnResetDialogState(_ purecef.Browser) {
214272 // nothing: resolve already cleared the pending state, and a late notification
215273 // must not clear a newer dialog.
216274 func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {}
217-
218-// continueCEFJSDialogOnUIThread answers the callback on the CEF UI thread.
219-func continueCEFJSDialogOnUIThread(callback purecef.JsdialogCallback, ok bool, input string) {
220- if callback == nil {
221- return
222- }
223- success := int32(0)
224- if ok {
225- success = 1
226- }
227- if purecef.CurrentlyOn(purecef.ThreadIDTidUi) == 1 {
228- callback.Cont(success, input)
229- return
230- }
231- task := cefNewTask(cefTaskFunc(func() { callback.Cont(success, input) }))
232- if cefPostTask(purecef.ThreadIDTidUi, task) != 1 {
233- callback.Cont(success, input)
234- }
235-}
@@ -20,9 +20,24 @@ import (
20 // Anti-abuse: while a dialog is open for a WebView, further OnJsdialog requests20 // Anti-abuse: while a dialog is open for a WebView, further OnJsdialog requests
21 // are suppressed (suppress_message=true) instead of being queued, so a page21 // are suppressed (suppress_message=true) instead of being queued, so a page
22 // cannot stack or spam dialogs.22 // cannot stack or spam dialogs.
23+//
24+// CEF reset semantics (javascript_dialog_manager.cc): OnResetDialogState is
25+// also delivered (a) right after a suppressed OnJsdialog and (b) at the end of
26+// every DialogClosed, i.e. inside/after each callback Cont. Those are not real
27+// resets and must not cancel the dialog the user is looking at, so
28+// jsDialogState carries a one-shot ignoreReset flag armed before suppressing,
29+// rejecting or continuing. Only WebView destruction and renderer termination
30+// cancel unconditionally.
23 31
24 // continueCEFJSDialog resolves a CEF dialog callback. It is a seam for tests.32 // continueCEFJSDialog resolves a CEF dialog callback. It is a seam for tests.
25-var continueCEFJSDialog = continueCEFJSDialogOnUIThread33+// CEF's callback implementation re-posts to the CEF UI thread by itself.
34+var continueCEFJSDialog = func(callback purecef.JsdialogCallback, ok bool, input string) {
35+ success := int32(0)
36+ if ok {
37+ success = 1
38+ }
39+ callback.Cont(success, input)
40+}
26 41
27 var _ purecef.JsdialogHandler = (*handlerSet)(nil)42 var _ purecef.JsdialogHandler = (*handlerSet)(nil)
28 43
@@ -34,8 +49,25 @@ type jsDialogCall struct {
34 49
35 // jsDialogState tracks the single pending dialog of a WebView.50 // jsDialogState tracks the single pending dialog of a WebView.
36 type jsDialogState struct {51 type jsDialogState struct {
37- mu sync.Mutex52+ mu sync.Mutex
38- current *jsDialogCall53+ current *jsDialogCall
54+ ignoreReset bool // swallow the next OnResetDialogState (see file comment)
55+}
56+
57+// armIgnoreReset makes the next OnResetDialogState a no-op.
58+func (s *jsDialogState) armIgnoreReset() {
59+ s.mu.Lock()
60+ s.ignoreReset = true
61+ s.mu.Unlock()
62+}
63+
64+// consumeIgnoreReset reports (and clears) a pending ignoreReset.
65+func (s *jsDialogState) consumeIgnoreReset() bool {
66+ s.mu.Lock()
67+ defer s.mu.Unlock()
68+ was := s.ignoreReset
69+ s.ignoreReset = false
70+ return was
39 }71 }
40 72
41 // begin registers a new pending dialog; false if one is already open.73 // begin registers a new pending dialog; false if one is already open.
@@ -47,6 +79,9 @@ func (s *jsDialogState) begin(cb purecef.JsdialogCallback) (*jsDialogCall, bool)
47 }79 }
48 call := &jsDialogCall{callback: cb}80 call := &jsDialogCall{callback: cb}
49 s.current = call81 s.current = call
82+ // A new dialog means any reset owed by the previous one has been delivered
83+ // (the page is blocked until then); never carry a stale flag across.
84+ s.ignoreReset = false
50 return call, true85 return call, true
51 }86 }
52 87
@@ -67,6 +102,9 @@ func (s *jsDialogState) resolve(call *jsDialogCall, ok bool, input string) bool
67 if s.current == call {102 if s.current == call {
68 s.current = nil103 s.current = nil
69 }104 }
105+ // Armed before Cont: CEF may deliver DialogClosed + OnResetDialogState
106+ // synchronously from inside it.
107+ s.ignoreReset = true
70 s.mu.Unlock()108 s.mu.Unlock()
71 continueCEFJSDialog(call.callback, ok, input)109 continueCEFJSDialog(call.callback, ok, input)
72 return true110 return true
@@ -146,6 +184,10 @@ func (h *handlerSet) OnJsdialog(
146 if h == nil || h.wv == nil || callback == nil {184 if h == nil || h.wv == nil || callback == nil {
147 return 0185 return 0
148 }186 }
187+ if h.wv.destroyed.Load() {
188+ // Shutting down: the GTK loop may be blocked. Let CEF dismiss it.
189+ return 0
190+ }
149 typ, ok := jsDialogTypeFromCEF(dialogType)191 typ, ok := jsDialogTypeFromCEF(dialogType)
150 if !ok {192 if !ok {
151 return 0193 return 0
@@ -156,7 +198,9 @@ func (h *handlerSet) OnJsdialog(
156 }198 }
157 call, ok := h.wv.jsDialogs.begin(callback)199 call, ok := h.wv.jsDialogs.begin(callback)
158 if !ok {200 if !ok {
159- // A dialog is already open: suppress rather than stack/spam.201+ // A dialog is already open: suppress rather than stack/spam. CEF will
202+ // follow up with OnResetDialogState, which must not cancel that dialog.
203+ h.wv.jsDialogs.armIgnoreReset()
160 if suppressMessage != nil {204 if suppressMessage != nil {
161 *suppressMessage = 1205 *suppressMessage = 1
162 }206 }
@@ -175,13 +219,18 @@ func (h *handlerSet) OnJsdialog(
175 // OnBeforeUnloadDialog implements purecef.JsdialogHandler.219 // OnBeforeUnloadDialog implements purecef.JsdialogHandler.
176 func (h *handlerSet) OnBeforeUnloadDialog(220 func (h *handlerSet) OnBeforeUnloadDialog(
177 _ purecef.Browser,221 _ purecef.Browser,
178- messageText string,222+ _ string, // CEF always passes a fixed string; the UI shows Dumber's own text.
179 isReload int32,223 isReload int32,
180 callback purecef.JsdialogCallback,224 callback purecef.JsdialogCallback,
181 ) bool {225 ) bool {
182 if h == nil || h.wv == nil || callback == nil {226 if h == nil || h.wv == nil || callback == nil {
183 return false227 return false
184 }228 }
229+ if h.wv.destroyed.Load() {
230+ // CloseBrowser dispatches beforeunload; accept it instead of waiting
231+ // on UI that may never run. CEF accepts the unload when we return false.
232+ return false
233+ }
185 ui := h.wv.jsDialogUICallbacks()234 ui := h.wv.jsDialogUICallbacks()
186 if ui == nil || ui.OnJSDialog == nil {235 if ui == nil || ui.OnJSDialog == nil {
187 return false236 return false
@@ -189,24 +238,33 @@ func (h *handlerSet) OnBeforeUnloadDialog(
189 call, ok := h.wv.jsDialogs.begin(callback)238 call, ok := h.wv.jsDialogs.begin(callback)
190 if !ok {239 if !ok {
191 // Another dialog is open: stay on the page rather than stacking.240 // Another dialog is open: stay on the page rather than stacking.
241+ // The Cont below makes CEF send a reset that must not cancel it.
242+ h.wv.jsDialogs.armIgnoreReset()
192 continueCEFJSDialog(callback, false, "")243 continueCEFJSDialog(callback, false, "")
193 return true244 return true
194 }245 }
195 h.wv.presentJSDialog(call, port.JSDialogRequest{246 h.wv.presentJSDialog(call, port.JSDialogRequest{
196 Type: port.JSDialogBeforeUnload,247 Type: port.JSDialogBeforeUnload,
197 Origin: h.wv.URI(),248 Origin: h.wv.URI(),
198- Message: messageText,
199 IsReload: isReload != 0,249 IsReload: isReload != 0,
200 }, ui)250 }, ui)
201 return true251 return true
202 }252 }
203 253
204 // OnResetDialogState implements purecef.JsdialogHandler: CEF canceled pending254 // OnResetDialogState implements purecef.JsdialogHandler: CEF canceled pending
205-// dialogs (navigation, close). Close the UI and resolve the callback.255+// dialogs (navigation, close). Close the UI and resolve the callback, unless
256+// this is the echo of a suppress/reject/Cont (see file comment).
257+//
258+// The UI reset (OnJSDialogReset) carries no dialog identity: it hides whatever
259+// the pane shows. That is safe because a pane shows at most one dialog and a
260+// new one is only presented after the previous call was resolved.
206 func (h *handlerSet) OnResetDialogState(_ purecef.Browser) {261 func (h *handlerSet) OnResetDialogState(_ purecef.Browser) {
207 if h == nil || h.wv == nil {262 if h == nil || h.wv == nil {
208 return263 return
209 }264 }
265+ if h.wv.jsDialogs.consumeIgnoreReset() {
266+ return
267+ }
210 h.wv.cancelJSDialogs()268 h.wv.cancelJSDialogs()
211 }269 }
212 270
@@ -214,22 +272,3 @@ func (h *handlerSet) OnResetDialogState(_ purecef.Browser) {
214 // nothing: resolve already cleared the pending state, and a late notification272 // nothing: resolve already cleared the pending state, and a late notification
215 // must not clear a newer dialog.273 // must not clear a newer dialog.
216 func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {}274 func (h *handlerSet) OnDialogClosed(_ purecef.Browser) {}
217-
218-// continueCEFJSDialogOnUIThread answers the callback on the CEF UI thread.
219-func continueCEFJSDialogOnUIThread(callback purecef.JsdialogCallback, ok bool, input string) {
220- if callback == nil {
221- return
222- }
223- success := int32(0)
224- if ok {
225- success = 1
226- }
227- if purecef.CurrentlyOn(purecef.ThreadIDTidUi) == 1 {
228- callback.Cont(success, input)
229- return
230- }
231- task := cefNewTask(cefTaskFunc(func() { callback.Cont(success, input) }))
232- if cefPostTask(purecef.ThreadIDTidUi, task) != 1 {
233- callback.Cont(success, input)
234- }
235-}
M internal/infrastructure/cef/jsdialog_handler_test.go +116 -2
@@ -12,6 +12,9 @@ import (
1212
1313 type stubJSDialogCallback struct {
1414 calls []jsDialogAnswer
15+ // onCont mimics CEF's DialogClosed ordering: OnDialogClosed and
16+ // OnResetDialogState are delivered from inside Cont.
17+ onCont func()
1518 }
1619
1720 type jsDialogAnswer struct {
@@ -21,6 +24,17 @@ type jsDialogAnswer struct {
2124
2225 func (s *stubJSDialogCallback) Cont(success int32, userInput string) {
2326 s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput})
27+ if s.onCont != nil {
28+ s.onCont()
29+ }
30+}
31+
32+// echoCEFClose makes cb deliver CEF's DialogClosed + reset echo from Cont.
33+func echoCEFClose(h *handlerSet, cb *stubJSDialogCallback) {
34+ cb.onCont = func() {
35+ h.OnDialogClosed(nil)
36+ h.OnResetDialogState(nil)
37+ }
2438 }
2539
2640 // useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread).
@@ -176,8 +190,9 @@ func TestOnBeforeUnloadDialog(t *testing.T) {
176190 cb := &stubJSDialogCallback{}
177191
178192 require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb))
193+ // CEF's fixed message text is not forwarded.
179194 require.Equal(t, []port.JSDialogRequest{{
180- Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", Message: "unsaved", IsReload: true,
195+ Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", IsReload: true,
181196 }}, ui.reqs)
182197
183198 // A second beforeunload while open is answered "stay" immediately.
@@ -220,7 +235,7 @@ func TestDestroyCancelsPendingJSDialog(t *testing.T) {
220235 cb := &stubJSDialogCallback{}
221236 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
222237
223- wv.cancelJSDialogs()
238+ wv.Destroy()
224239
225240 require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
226241 require.Equal(t, 1, ui.resets)
@@ -240,3 +255,102 @@ func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) {
240255 require.Empty(t, ui.reqs)
241256 require.Len(t, cb.calls, 1)
242257 }
258+
259+func TestResetEchoAfterSuppressDoesNotCancelOpenDialog(t *testing.T) {
260+ useDirectJSDialogContinue(t)
261+ ui := &jsDialogUIRecorder{handled: true}
262+ h := &handlerSet{wv: newJSDialogWebView(ui)}
263+ first := &stubJSDialogCallback{}
264+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
265+
266+ // A spammer is suppressed; CEF then sends OnResetDialogState.
267+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", &stubJSDialogCallback{}, new(int32))
268+ h.OnResetDialogState(nil)
269+
270+ require.Empty(t, first.calls, "dialog #1 must stay pending")
271+ require.Zero(t, ui.resets)
272+
273+ // A real reset afterwards still cancels it.
274+ h.OnResetDialogState(nil)
275+ require.Equal(t, []jsDialogAnswer{{ok: false}}, first.calls)
276+ require.Equal(t, 1, ui.resets)
277+}
278+
279+func TestResetEchoAfterRejectedBeforeUnloadDoesNotCancelOpenDialog(t *testing.T) {
280+ useDirectJSDialogContinue(t)
281+ ui := &jsDialogUIRecorder{handled: true}
282+ h := &handlerSet{wv: newJSDialogWebView(ui)}
283+ first := &stubJSDialogCallback{}
284+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "1", "", first, new(int32))
285+
286+ other := &stubJSDialogCallback{}
287+ echoCEFClose(h, other)
288+ require.True(t, h.OnBeforeUnloadDialog(nil, "", 0, other))
289+
290+ require.Empty(t, first.calls)
291+ require.Zero(t, ui.resets)
292+}
293+
294+func TestCEFCloseEchoDoesNotCancelNewerDialog(t *testing.T) {
295+ useDirectJSDialogContinue(t)
296+ ui := &jsDialogUIRecorder{handled: true}
297+ h := &handlerSet{wv: newJSDialogWebView(ui)}
298+ first := &stubJSDialogCallback{}
299+ echoCEFClose(h, first)
300+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
301+
302+ ui.respond[0](true, "") // Cont -> OnDialogClosed + OnResetDialogState (echo)
303+ require.Equal(t, []jsDialogAnswer{{ok: true}}, first.calls)
304+ require.Zero(t, ui.resets, "echo must not hide the UI")
305+
306+ // A newer dialog is unaffected by a trailing echo that arrives late.
307+ second := &stubJSDialogCallback{}
308+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
309+ h.OnResetDialogState(nil) // a genuine reset
310+ require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls)
311+}
312+
313+func TestNoStaleIgnoreAcrossDialogs(t *testing.T) {
314+ useDirectJSDialogContinue(t)
315+ ui := &jsDialogUIRecorder{handled: true}
316+ h := &handlerSet{wv: newJSDialogWebView(ui)}
317+ // Answered without a CEF echo (e.g. reset-then-late-answer path).
318+ first := &stubJSDialogCallback{}
319+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
320+ ui.respond[0](true, "")
321+
322+ second := &stubJSDialogCallback{}
323+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
324+ h.OnResetDialogState(nil)
325+
326+ require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls, "real reset must cancel the new dialog")
327+}
328+
329+func TestDestroyedWebViewDoesNotShowDialogs(t *testing.T) {
330+ ui := &jsDialogUIRecorder{handled: true}
331+ wv := newJSDialogWebView(ui)
332+ wv.destroyed.Store(true)
333+ h := &handlerSet{wv: wv}
334+ cb := &stubJSDialogCallback{}
335+ var suppress int32
336+
337+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
338+ require.False(t, h.OnBeforeUnloadDialog(nil, "", 0, cb))
339+ require.Zero(t, suppress)
340+ require.Empty(t, ui.reqs)
341+ require.Empty(t, cb.calls)
342+}
343+
344+func TestRenderProcessTerminatedCancelsPendingJSDialog(t *testing.T) {
345+ useDirectJSDialogContinue(t)
346+ ui := &jsDialogUIRecorder{handled: true}
347+ wv := newJSDialogWebView(ui)
348+ h := &handlerSet{wv: wv}
349+ cb := &stubJSDialogCallback{}
350+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
351+
352+ h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "")
353+
354+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
355+ require.Equal(t, 1, ui.resets)
356+}
@@ -12,6 +12,9 @@ import (
12 12
13 type stubJSDialogCallback struct {13 type stubJSDialogCallback struct {
14 calls []jsDialogAnswer14 calls []jsDialogAnswer
15+ // onCont mimics CEF's DialogClosed ordering: OnDialogClosed and
16+ // OnResetDialogState are delivered from inside Cont.
17+ onCont func()
15 }18 }
16 19
17 type jsDialogAnswer struct {20 type jsDialogAnswer struct {
@@ -21,6 +24,17 @@ type jsDialogAnswer struct {
21 24
22 func (s *stubJSDialogCallback) Cont(success int32, userInput string) {25 func (s *stubJSDialogCallback) Cont(success int32, userInput string) {
23 s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput})26 s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput})
27+ if s.onCont != nil {
28+ s.onCont()
29+ }
30+}
31+
32+// echoCEFClose makes cb deliver CEF's DialogClosed + reset echo from Cont.
33+func echoCEFClose(h *handlerSet, cb *stubJSDialogCallback) {
34+ cb.onCont = func() {
35+ h.OnDialogClosed(nil)
36+ h.OnResetDialogState(nil)
37+ }
24 }38 }
25 39
26 // useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread).40 // useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread).
@@ -176,8 +190,9 @@ func TestOnBeforeUnloadDialog(t *testing.T) {
176 cb := &stubJSDialogCallback{}190 cb := &stubJSDialogCallback{}
177 191
178 require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb))192 require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb))
193+ // CEF's fixed message text is not forwarded.
179 require.Equal(t, []port.JSDialogRequest{{194 require.Equal(t, []port.JSDialogRequest{{
180- Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", Message: "unsaved", IsReload: true,195+ Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", IsReload: true,
181 }}, ui.reqs)196 }}, ui.reqs)
182 197
183 // A second beforeunload while open is answered "stay" immediately.198 // A second beforeunload while open is answered "stay" immediately.
@@ -220,7 +235,7 @@ func TestDestroyCancelsPendingJSDialog(t *testing.T) {
220 cb := &stubJSDialogCallback{}235 cb := &stubJSDialogCallback{}
221 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))236 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
222 237
223- wv.cancelJSDialogs()238+ wv.Destroy()
224 239
225 require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)240 require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
226 require.Equal(t, 1, ui.resets)241 require.Equal(t, 1, ui.resets)
@@ -240,3 +255,102 @@ func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) {
240 require.Empty(t, ui.reqs)255 require.Empty(t, ui.reqs)
241 require.Len(t, cb.calls, 1)256 require.Len(t, cb.calls, 1)
242 }257 }
258+
259+func TestResetEchoAfterSuppressDoesNotCancelOpenDialog(t *testing.T) {
260+ useDirectJSDialogContinue(t)
261+ ui := &jsDialogUIRecorder{handled: true}
262+ h := &handlerSet{wv: newJSDialogWebView(ui)}
263+ first := &stubJSDialogCallback{}
264+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
265+
266+ // A spammer is suppressed; CEF then sends OnResetDialogState.
267+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", &stubJSDialogCallback{}, new(int32))
268+ h.OnResetDialogState(nil)
269+
270+ require.Empty(t, first.calls, "dialog #1 must stay pending")
271+ require.Zero(t, ui.resets)
272+
273+ // A real reset afterwards still cancels it.
274+ h.OnResetDialogState(nil)
275+ require.Equal(t, []jsDialogAnswer{{ok: false}}, first.calls)
276+ require.Equal(t, 1, ui.resets)
277+}
278+
279+func TestResetEchoAfterRejectedBeforeUnloadDoesNotCancelOpenDialog(t *testing.T) {
280+ useDirectJSDialogContinue(t)
281+ ui := &jsDialogUIRecorder{handled: true}
282+ h := &handlerSet{wv: newJSDialogWebView(ui)}
283+ first := &stubJSDialogCallback{}
284+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "1", "", first, new(int32))
285+
286+ other := &stubJSDialogCallback{}
287+ echoCEFClose(h, other)
288+ require.True(t, h.OnBeforeUnloadDialog(nil, "", 0, other))
289+
290+ require.Empty(t, first.calls)
291+ require.Zero(t, ui.resets)
292+}
293+
294+func TestCEFCloseEchoDoesNotCancelNewerDialog(t *testing.T) {
295+ useDirectJSDialogContinue(t)
296+ ui := &jsDialogUIRecorder{handled: true}
297+ h := &handlerSet{wv: newJSDialogWebView(ui)}
298+ first := &stubJSDialogCallback{}
299+ echoCEFClose(h, first)
300+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
301+
302+ ui.respond[0](true, "") // Cont -> OnDialogClosed + OnResetDialogState (echo)
303+ require.Equal(t, []jsDialogAnswer{{ok: true}}, first.calls)
304+ require.Zero(t, ui.resets, "echo must not hide the UI")
305+
306+ // A newer dialog is unaffected by a trailing echo that arrives late.
307+ second := &stubJSDialogCallback{}
308+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
309+ h.OnResetDialogState(nil) // a genuine reset
310+ require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls)
311+}
312+
313+func TestNoStaleIgnoreAcrossDialogs(t *testing.T) {
314+ useDirectJSDialogContinue(t)
315+ ui := &jsDialogUIRecorder{handled: true}
316+ h := &handlerSet{wv: newJSDialogWebView(ui)}
317+ // Answered without a CEF echo (e.g. reset-then-late-answer path).
318+ first := &stubJSDialogCallback{}
319+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
320+ ui.respond[0](true, "")
321+
322+ second := &stubJSDialogCallback{}
323+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
324+ h.OnResetDialogState(nil)
325+
326+ require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls, "real reset must cancel the new dialog")
327+}
328+
329+func TestDestroyedWebViewDoesNotShowDialogs(t *testing.T) {
330+ ui := &jsDialogUIRecorder{handled: true}
331+ wv := newJSDialogWebView(ui)
332+ wv.destroyed.Store(true)
333+ h := &handlerSet{wv: wv}
334+ cb := &stubJSDialogCallback{}
335+ var suppress int32
336+
337+ require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
338+ require.False(t, h.OnBeforeUnloadDialog(nil, "", 0, cb))
339+ require.Zero(t, suppress)
340+ require.Empty(t, ui.reqs)
341+ require.Empty(t, cb.calls)
342+}
343+
344+func TestRenderProcessTerminatedCancelsPendingJSDialog(t *testing.T) {
345+ useDirectJSDialogContinue(t)
346+ ui := &jsDialogUIRecorder{handled: true}
347+ wv := newJSDialogWebView(ui)
348+ h := &handlerSet{wv: wv}
349+ cb := &stubJSDialogCallback{}
350+ h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
351+
352+ h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "")
353+
354+ require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
355+ require.Equal(t, 1, ui.resets)
356+}
M internal/infrastructure/cef/webview_handlers.go +4 -0
@@ -1298,6 +1298,10 @@ const maxConsecutiveCrashes = 3
12981298
12991299 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
13001300 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
1301+ // CEF never resets dialog state on a renderer crash; don't leave the UI
1302+ // (and the dead page's callback) hanging.
1303+ h.wv.cancelJSDialogs()
1304+
13011305 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
13021306 return // suppress to break the loop
13031307 }
@@ -1298,6 +1298,10 @@ const maxConsecutiveCrashes = 3
1298 1298
1299 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.1299 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
1300 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {1300 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
1301+ // CEF never resets dialog state on a renderer crash; don't leave the UI
1302+ // (and the dead page's callback) hanging.
1303+ h.wv.cancelJSDialogs()
1304+
1301 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {1305 if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
1302 return // suppress to break the loop1306 return // suppress to break the loop
1303 }1307 }

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