Repository navigation
(window): ask before a window close ends the app - #478
Conversation
devsuitup
left a comment
There was a problem hiding this comment.
Review at a72aa4e. The confirmation itself is wired through the existing unsaved-edits check, which is the right place. One point blocks, found by tracing the code.
Blocking
- A renderer that hangs after the ACK makes the window impossible to close (
public/file-panel.jsonUnsavedCheck,unsaved-guard.jsunsaved-check-ack). The renderer sends the ACK first, and the ACK cancels the main-side timeout. If the renderer then blocks (a stuckconfirm, a hung page), neither a result nor a timeout ever arrives, and both the close and the deliberate quit stay pending. The weakness existed for unsaved edits; this PR extends it to every close, including a window with nothing unsaved. Fix: after the ACK, keep a longer bound (or re-arm the timer), or let a second close request override the pending one. Test: ACK, never answer, then check that a second close or the bound ends it.
Non-blocking
- The new
confirm('Close Switchboard?')has no test for its default focus or Enter/Escape behaviour (test/dom-file-panel-unsaved-guard.test.js: the confirmation is stubbed). Enter approving a close by accident is plausible and not verified. - The CHANGELOG entry must end with
(#478).
393 tests in the nine related files pass locally on Node 24. Not verified: Node 20/22 with c8, and the native dialog.
|
Tested on Linux (Ubuntu, GNOME on Wayland), PR head a72aa4e. Setup. An isolated instance with fixture HOME and plain-shell sessions, launched through Playwright Window close (
☰ → Quit ( Close over unsaved edits (dirty file tab). "Unsaved file edits" is shown, and "Close Switchboard?" is not asked. Cancel keeps the app and the edit, and Discard exits at once. Works. Blocking point: the renderer ACKs, then hangs. This reproduces live. The hang was a 60 s busy loop in place of
No Found alongside, in the existing guard: the 2.5 s bound when the renderer hangs before the ACK. The bound fires and calls Not run:
The PR's test files pass: |
a72aa4e to
150b213
Compare
|
Thanks, and thanks @jbr-sekoia for the live reproduction and for finding the pre-ack bound problem. Rebased on current main (eaf59ff); the fixes are in 150b213, on top of the rebased original commit. Blocking: a renderer that hangs after the ACK I took the second option: a repeated request ends the pending check, but only once the renderer has shown it cannot answer.
The 2.5 s bound before the ACK (found in the live test, predating this PR): when it fires, the window is now destroyed instead of closed. Tests (
Non-blocking
Each part was removed in turn, and each removal fails a test: the probe on close, the probe on quit, the destroy, the forced bound, the pong handling, the ACK condition and the dialog focus. The related suites pass (646). On the full suite, the only failures are the CLI-state canary, #479 changes the same |
devsuitup
left a comment
There was a problem hiding this comment.
Review at 150b213, rebased on current main. Thanks: the earlier blocker is fixed (a renderer that stops answering after the ACK is now probed with a ping and the close recovers), and so are the Cancel/Enter/Escape tests and the changelog suffix (#478). 409 tests in 12 related files pass locally on Node 24. One point blocks until it is shown not to happen; the reviewer rated it plausible, not reproduced, and I re-read the path.
Blocking (to verify)
- The probe can destroy the window while a native dialog is waiting for the user (
unsaved-guard.js,probe~83 andend~98;public/viewer-panel.js~477,public/file-panel.js). On a deliberate quit,beforeQuitpings the renderer and, if no pong comes back withinprobeMs(1.5 s), callsentry.finish(true, true)and thenwin.destroy(). The check itself waits on native dialogs: theconfirm('Close Switchboard?'), and the overwrite confirmation shown while Save is waiting. If the renderer does not answer the ping while such a dialog is open (it depends on whether the modal's nested loop dispatches the preload'sipcRenderer.on('unsaved-ping')handler), the window is destroyed after 1.5 s with the dialog unanswered, and unsaved edits are lost; for the close prompt, the confirmation disappears by itself. Please verify in a real Electron run, and add a test through the real handler: with a dialog open, a slow answer (longer thanprobeMs) must not force the close. If the pong is not served during a modal, pause the probe while a dialog is open (the renderer can tell main when it opens and closes one).
Non-blocking
- Focus is not restored after Cancel (
public/file-panel.js~807). - Rationale comments at
unsaved-guard.js~35 and ~94 go beyond the one-line pointer allowed in code.
Not run: native Electron behaviour, a real Windows shutdown, Node 20/22 with c8, lint, fresh CI, the combination with #479 and #441. CI on this head is not approved yet.
3d1674e to
62fe805
Compare
|
Thanks. Rebased on 805d064, the commit before #499: my token cannot push a branch that contains the Blocking (to verify)
Non-blocking
Each fix was removed in turn, and each removal fails a test: ignoring the flag, no re-arm when the dialog closes, never clearing the flag, the crash and navigation resets, any sender counting, the probe token and the pong re-arm, no wrapper, no The full suite has 17 failures, the same 17 as upstream/main in my sandboxed environment: the CLI-state canary, |
devsuitup
left a comment
There was a problem hiding this comment.
Review at 62fe805. Thanks for the quick turnaround and the careful write-up. Both earlier blockers are addressed in the code, and the tests catch it when each fix is removed (checked locally by mutation: removing the dialog pause, win.destroy(), the quit exemption or the native-dialog wrapper each turns tests red). 62fe805 merges cleanly into current main (75ab0ad); the two PR test files pass on the merge (39/39).
Earlier findings
- Renderer hangs after the ACK: resolved; a repeated close or quit now probes the page (
unsaved-guard.js:98-114,:120-158). A single close after an ACK still waits for a repeat, as documented. - Enter/Escape on the close question: resolved (
public/file-panel.js:725-735,initialFocus: 'cancel'). (#478)suffix: resolved (CHANGELOG.md:12).- Probe vs. a native dialog: resolved in code (
public/file-panel.js:709-722;unsaved-guard.js:24-30,:61-66,:142-143); not yet seen in a live Electron run. - Focus after Cancel: resolved (
public/file-panel.js:625,:678,:732). - Rationale comments: resolved.
- Pre-ACK bound that did not close a hung window: resolved (
unsaved-guard.js:64,:116-118).
Major
-
The changelog entry lands in the released v0.0.90 section on merge (
CHANGELOG.md:12). v0.0.90 was released after your base and moved the old## Unreleaseditems under## v0.0.90 — 2026-10-09. Merged into main, the hunk applies by context with no conflict and the entry ends up under## v0.0.90 → ### Changed; the changelog check only looks for the file in the diff, so it stays green. Since you cannot rebase past #499, we can move it under## Unreleased → ### Changedwhen merging — please confirm that is fine with you. -
A window close is silently cancelled while another choice dialog is open (
public/file-panel.js:725-735withpublic/choice-dialog.js:25).showChoiceDialogreturnsnullat once if a.choice-dialog-overlayexists, andconfirmWindowClosereadsnullas Cancel. With the Archive folder dialog open (public/sidebar.js:1189), Alt+F4 or the taskbar's Close window shows nothing and the window stays, with no feedback. Tell "already open" apart from "cancelled" (dismiss the other dialog first, or show the close question anyway), and add a test: with a choice dialog open, aclosecheck still shows "Close Switchboard?" or resolves deliberately, never as a cancel. -
Evidence for the bug-fix part, and integration coverage. The PR also fixes a bug present on main (the pre-ACK bound now destroys a hung window instead of calling
close()on it). For bug fixes we ask for (a) a CI run that fails before the fix — the regression test pushed alone, failing in a required job, then the fix — and (b) integration coverage where feasible: all the new coverage drives a fakeBrowserWindow, while the requirede2ejob already runs Playwright Electron journeys. Could you add ane2e/journey for close → Cancel keeps the window and the PTY, close → Close exits, and ☰ → Quit does not ask, and provide the CI-red step for the hung-window fix? If either is not possible in CI, please say concretely why;e2eruns green on this head.
Minor
unsaved-guard.js:64,:116-118: a close during startup (beforeinitFilePanelregistersonUnsavedCheck) or while the page is busy for more than 2.5 s gets no ACK; the bound then forces the close anddestroy()ends every session without the new question, where it used to go throughclose(). Consider forcing only once the page has also failed a ping, or document that the question is skipped there inwindow-frame.md.main.js:3262:confirmQuit(updater install) goes throughask()and dropsforced, so a page hung before the ACK still blocks the window close afterquitAndInstall(). Predates this PR; reusingaskFullandend()would make the paths consistent.unsaved-guard.js:63: the native-dialog pause has no upper bound, so SIGTERM or a Linux/macOS logout wait indefinitely while a nativeconfirmis open. Consider a long ceiling (minutes) for a quit that does not come from the user.
Suggestions
unsaved-guard.js:18-21:unsaved-pongdoes not checkevent.sender; onlyentry.wcshould be able to answer.- On macOS the app stays in the Dock after the window closes (
main.js:3468), so "Close Switchboard?" is slightly off there.
Not verified: native Electron behaviour, a real Windows shutdown, macOS, and the combination with #479 and #441.
Changes requested: 0 blocking, 3 major, 3 minor, 2 suggestions.
62fe805 to
6fda14c
Compare
|
Thanks for the rebase. One thing the rebase did not fix: the |
eb0282c to
8ec5ef5
Compare
|
Thanks. Based on #540 now, on current main (a02f96c). #540 is the hung-window fix split out, as asked in Major 3. The earlier three commits of this PR are squashed into 44db05e on top of it, because they rewrote the same lines #540 now changes. This round is 93e05e8, plus 8ec5ef5 for the e2e fixture. Major
Minor Suggestions
Mutation check: each of these changes was removed in turn and each removal fails a test:
Full suite at 93e05e8: only the failures this sandboxed environment has on main (the CLI-state canary, Not run locally: Electron, which segfaults in this environment, so the e2e journeys were checked only in CI. Also not run: a real hung renderer, a real SIGTERM with a native dialog open, macOS and Windows. |
devsuitup
left a comment
There was a problem hiding this comment.
Re-review at 8ec5ef5 — request changes (1 major inherited from #540, 4 minor, 4 suggestions). The #478 commits (44db05e, 93e05e8, 8ec5ef5) close every finding from the 62fe805 review except the macOS wording.
Previous findings
| Finding | Status |
|---|---|
| M1 changelog under a released section | closed (CHANGELOG.md:7-8, Unreleased / Changed) |
| M2 close silently cancelled over another choice dialog | closed (choice-dialog.js:29-33 replace, file-panel.js:726-737; test 10 fails on 44db05e) |
| M3a e2e coverage | closed (e2e/close-window.spec.js, 2/2 in job 114183554769) |
| M3b red before the fix | closed via #540 (run 38039912120 on f1e68a9 fails the 3 new tests, green on 1fb50e9) |
| m4 startup/busy page closes without the question | documented only — see Major |
m5 confirmQuit drops the forced end |
closed (test 49) |
| m6 native-dialog pause unbounded | closed (5-min ceiling unsaved-guard.js:67, tests 46/47) |
| S pong from any page | closed (unsaved-guard.js:21-24, test 48) |
| S macOS wording ("Close Switchboard?" while the app stays in the Dock) | open |
Local replays of the head's two test files (Node 22): head 50/0, main 29/21, 1fb50e9 32/18, 44db05e 45/5 — each 93e05e8 fix has a test that fails without it. CI green on 8ec5ef5 (runs 38041869326 / 38041869325).
Major — a busy page loses the question and is destroyed (unsaved-guard.js:124-134, :67; CHANGELOG.md:8)
Inherited from #540 and extended: a page busy more than 2.5 s does not acknowledge the check, the close proceeds without the question and the window is destroyed ~5.5 s after the click; #478 adds a second fixed bound, the 1.5 s probe, which destroys without calling close() first. Renderer stalls of 8–10 s happen today on tab reveal (#526), so a stray click on the close button during one — the very case this PR is for — ends every session with no question, and drops the debounced persistWorkingSet (and #479's flush once it lands). The head test a page that never acknowledges is closed, and destroyed when that close does not finish asserts exactly this. The changelog says closing "asks for confirmation first" unconditionally. Please settle it in #540 (e.g. key on Electron's unresponsive/responsive), and for the 'close' reason do not force before an acknowledgement unless the user closes again or the page is reported unresponsive; qualify the changelog line.
Minor
.ai/contexts/window-frame.md:258,CHANGELOG.md:8: on Windows Electron's quit role reads "Exit" and has no Ctrl+Q accelerator (window-frame.js:45sets none), so "Ctrl+Q" / "Quit" are wrong there.unsaved-guard.js:136-150(plausible): an external quit (SIGTERM, Linux/macOS logout) arriving while "Close Switchboard?" is shown joins the pending check; the page answers the ping so no bound runs, and Cancel drops the quit (if (!proceed) return;).unsaved-guard.js:67,:103-122(plausible): the 1.5 s probe treats a busy page as hung when a SIGTERM or an updater install counts as the second close.window-frame.md:318(and:276): the "not run: Electron does not start in the sandboxed session" paragraph is authoring history, stale now that e2e runs Electron; two lines are not wrapped like the rest.
Suggestions
e2e/close-window.spec.js:40,:63only check the process exited; assertexitCode === 0so a crash cannot pass.taskkillwithout/Fnow shows the question; a tool that then escalates to/F(possibly the NSIS running-app check, not verified) skips the quit cleanup — worth documenting.- #479 conflicts here (
preload.js,file-panel.js,unsaved-guard.js); its flush runs for any reason but'reload', so keep it afterconfirmWindowClosewhen resolving. - macOS wording, as above.
The e2e fixture change (8ec5ef5) neither hides a teardown failure nor leaves Electron running: Playwright's close() calls app.quit(), and a journey failing before exit still goes through capture, close, wait, then forced kill.
Not run here: native dialogs in Electron, a real Windows shutdown/taskkill, macOS, the combination with #479/#441.
8ec5ef5 to
ea62821
Compare
|
Addressed at ea62821, rebased on #540 (fba2056). The #478 part is now a single commit, because the guard was reworked. Major (a busy page loses the question and is destroyed).
Minor 1 (Exit / Ctrl+Q). The changelog says "Quit, or Exit on Windows", and window-frame.md gives the accelerators per platform. Minor 2 (an external quit dropped by Cancel). A quit that joins a pending close check now upgrades its reason. Main sends Minor 3 (SIGTERM/updater as the second close). Minor 4. The stale "not run" paragraph is gone, and the sections are rewrapped and rewritten for the new rules. Suggestions.
Mutation checks. 18 single mutations of the new guard, renderer and dialog code each fail a test. The one exception is the renderer ignoring a reason for an id it no longer tracks, which only avoids a stale map entry. Locally (Node 22). Full suite 5110 pass / 17 fail, all of them the sandboxed-session baseline (CLI-state canary, 4 sandbox-wrapper, 12 test-pr "isolated" tests). The touched files pass 73/73. |
ea62821 to
08fea1c
Compare
devsuitup
left a comment
There was a problem hiding this comment.
Re-review at 08fea1c — request changes, wording only (3 minor). The behaviour is acceptable; what is left is docs and changelog that do not match the code. Every required check is green (run 38071187146, e2e job 114268758666); locally (Node 22) unsaved-guard, dom-file-panel-unsaved-guard and dom-choice-dialog 75/75.
On ea62821 the required e2e was red (hung-window.spec.js:14 timed out, run 38070586412) while it passed on #540's head: a window already flagged unresponsive and then closed once never ended. 08fea1c settles it as a design choice: a 'close' check unacknowledged after 2.5 s is marked overdue and the next close destroys the window (probeOrForce), a double click does not; new test "closing again a page that left the check unacknowledged for the bound ends it; a double click does not", and the hung-window journey now closes twice. window-frame.md:293-297 states that a single close on a hung page never ends it. The other 8ec5ef5 points (Windows "Exit"/no Ctrl+Q, external quit dropped on Cancel, a quit counted as the second close, stale paragraph, exitCode 0, taskkill, macOS wording) are closed.
Minor
window-frame.md:267says a page "still starting" shows the question once it is free. A close beforeinitFilePanel(app.js:1939; the window is shown at creation) sends anunsaved-checknobody listens to yet, so the question never shows; a second close more than 2.5 s later destroys the window without it (no longer dead for the session, as on ea62821). Say so, or have the renderer re-ask an unacknowledged check once ready (did-finish-loador a ready signal).CHANGELOG.md:11(#540's entry) says the window "closes as before, and once the system reports it not responding … closed anyway". With #478 the guard holds a window close beforebeforeunload, so Electron never reports the page unresponsive and a single close waits; whichever of #540/#478 lands second should fix that line.- The quit path still calls
win.close()after the 2.5 s bound (beforeQuit,confirmQuit), so per my #540 review a page still busy ~7.5 s after a quit is destroyed; the window-frame paragraph "A check the page never acknowledged for a quit…" should say it. (This does not apply to a window close before the acknowledgement.)
Still open: the #479 conflict. Not run: Electron locally, native dialogs, macOS, the combination with #479.
|
Follow-up at 08fea1c. CI on ea62821 showed something the design had not covered: with a So for an unacknowledged close, a second close counts once the check has gone 2.5 s without an acknowledgement (
This is the trade-off you asked for: a single close of a hung window does nothing until the user closes it again, and a busy window is never closed without the question. |
|
Rebased on the reviewed #479, which is stacked on main 24ac041. Main already merged #479 at f39d5ea through #555, so #479 now carries only its late-save wait. Major (the conflict with #479): resolved as you described.
Minor 2, now a nit (a resent check during the exit write): a check is now forgotten only after
Nit 3 (5165954's message): not changed yet. That commit is now c119179, and its body still describes the fix, which is in 75e71f2. I will reword it when this branch is rebased onto main after #479 lands, so CI does not have to run again just for a message. Local: the full suite fails only the 13 known sandbox-baseline tests, and lint has 0 errors. |
|
Review of 8e1f08f: changes requested.
|
8e1f08f to
1969b7c
Compare
|
Addressed at 24aa642, rebased onto #479's new head 9f9c57f (main 421f77f). Major (changelog in a released section). The (#478) entry is under Unreleased / Changed. The released #540 line under v0.0.93 is restored to its published text; the Unreleased entry already says that a window that stopped responding needs a second close. Minor (#479 race: "Close Switchboard?" during a held approved quit). Fixed in #479 (9f9c57f): a joined check stays in flight until its late-save wait ends, so a window close in between joins it. The rebased #479 test Nit (one report releases every wait for an id). Each upgrade now carries a token (
c119179's message. Reworded during the rebase (now 977b852): it describes the tests and the contract they pin, not the fix. Locally: full suite fails only the 13 sandbox-baseline tests; lint 0 errors. |
devsuitup
left a comment
There was a problem hiding this comment.
Re-review at 24aa642 (stacked on #479 at 9f9c57f). Changes requested. The only blocking item is the CHANGELOG; the code is ready.
Previous review (8e1f08f)
| # | Finding | Status |
|---|---|---|
| 1 | Major: CHANGELOG entries in a released section | Reopened by the v0.0.95 cut. At 24aa642 the entry sits under Unreleased relative to 421f77f. Main is now abc9a54: ## Unreleased was renamed ## v0.0.95 — 2026-10-12 and a new, empty ## Unreleased opened above it. A trial merge onto main conflicts only on #479's line. The ### Changed (#478) entry merges without a conflict but lands under ## v0.0.95. Please rebase onto main (after #479) and put both entries under the new ## Unreleased. The restored #540 line under v0.0.93 now matches its published text. |
| 2 | Minor: #479 race | Closed for a single upgrade (fixed in #479 at 9f9c57f). Two upgrades still leave a gap; see the new nit below. |
| 3 | Nit: one "done" releases every wait for an id | Closed. Each wait has its own token (unsaved-guard.js:59-82, :95-97, :133), carried through preload.js:170,175 and public/file-panel.js:158-159. |
| 4 | c119179's message described the fix | Closed. That commit is now 977b852, and its body describes only the tests. |
New nit: with two upgrades, the first upgrade's report clears the in-flight state while the second upgrade is still waiting (unsaved-guard.js:99-108)
Each upgrade adds a link to joined.asked, and each link clears inflight when it resolves (:106). The close link therefore clears it before the quit link has received its own report. This contradicts viewer-panel.md:167: "Until then the joined question stays the one in flight".
Replay with the test file's setup():
- reload;
- a close and a quit join it;
- answer yes;
- send
unsaved-check-reason-donefor the close's token only; - call
confirmQuit(an install).
Result: main sends a second unsaved-check ([4,"quit"]), and quit() runs while that question is still pending.
The window is short, about one IPC round trip, or the difference between the two 2.5 s bounds on a hung page. Suggested fix: clear inflight only from the last link, for example with a pending-upgrade counter on joined, and add a test where the reports arrive in that order.
Red, then green (local, Node 22, unsaved-guard / dom-file-panel-unsaved-guard / dom-choice-dialog tests)
- At the tests-only commit 1969b7c: 92/95. The 3 failures are on assertions, at
dom-file-panel-unsaved-guard.test.js:361andunsaved-guard.test.js:372/:390. - At 24aa642: 95/95. The two older assertions loosened to
args.slice(0, 2)follow from the new IPC argument.
Other checks
- CI on 24aa642 is green (run 38218674005).
- On the merged tree, the full suite under Node 22 gives 5397 pass and 1 fail. The failure is the changelog/version check, caused by the CHANGELOG item above.
- eslint on the touched files: 0 errors.
- Comments are one-line pointers only.
Not run: Electron or e2e locally, a real hung renderer.
24aa642 to
dd9c5d9
Compare
|
Addressed review of 24aa642. Rebased onto #479 at 80c87e3. Backup of the old head:
Red, then green (CI on devsuitup):
Local, Node 22: full suite 5465 pass, 13 fail (sandbox-only: CLI-state canary and 12 test-pr "isolated" tests); Rebased commits: ba0d87e, b456c67, 1efd2f1, dc26fa6, 26eb265, 70937d4, d9e7798 (former 7bfe4d8, 977b852, 00aa634, b6ae309, ce07939, 1969b7c, 24aa642). The rebase conflict in 70937d4 ( |
devsuitup
left a comment
There was a problem hiding this comment.
Re-review at 22e6996 (stacked on #479 at 80c87e3). Approved.
| # | Previous finding (24aa642) | Status |
|---|---|---|
| 1 | Blocking: CHANGELOG entries under the released v0.0.95 | Closed. The ### Changed (#478) entry and the #479 line both sit under the new ## Unreleased, and nothing under ## v0.0.95 changes. The changelog check is green. |
| 2 | Nit: with two upgrades, the first report cleared inflight |
Closed. unsaved-guard.js:95 counts upgrades on the joined check, and :107-108 clears inflight only from the last link. viewer-panel.md:167 is updated to match. |
Red, then green (local, Node 22)
- At dd9c5d9,
unsaved-guard.test.jsis 54/55. The new test fails on its assertion attest/unsaved-guard.test.js:420(2 !== 1: a second question was sent). - At 22e6996 it is 55/55, and
unsaved-guard+dom-file-panel-unsaved-guard+dom-choice-dialogis 97/97.
Other checks
- Range-diff against 24aa642: the old commits differ only in their CHANGELOG hunks, and there are two new commits (test, then fix).
- origin/main (61bee93) is an ancestor of the head, so merging the stack is a fast-forward.
- CI on 22e6996 is green, including every required test job. The new commits add no code comments.
Merge order, for us: our pending #589 (the modal stack and the dialog queue) conflicts with this PR:
public/choice-dialog.js:replace/closeWithagainst the new queue;public/file-panel.js:showUnsavedEditsDialog'sfinish;- the unsaved-guard tests;
CHANGELOG.md.
#589 will be reworked on top of #479 and #478, not the other way round.
Not run: Electron or e2e locally, a real hung renderer.
|
#479 is merged into main as 45d8037 (squash). This branch still carries #479's original commits, so it now conflicts with main. Dropping them makes it clean. I checked this locally: rebasing only this PR's own commits onto main applies without conflict, and the resulting tree is byte-identical to 22e6996, the head I approved. (80c87e3 is #479's last head.) Once you push, I will check that the tree still matches 22e6996 and merge after CI. |
A stray click on the close button ended the app and every session in it.
A window close now asks "Close Switchboard?" ("Close the window?" on
macOS, where the app stays in the Dock) in the in-page choice dialog, with
Cancel focused. Quitting from the menu, an update install and a logout are
not asked again.
A close check has no bound before the page acknowledges it: a busy page
asks once it is free. A hung page is ended by a second close once the
check has gone unacknowledged for 2.5 s, or a ping unanswered for 1.5 s,
or when Electron reports it unresponsive; a quit never counts as that
second close. A quit that joins a pending close question upgrades its
reason, which closes the question as a yes. A native dialog in the page
holds the probe and pauses the quit bound, for at most 5 minutes.
… page is ready A close before initFilePanel sends an unsaved-check nobody listens to yet, so the question never shows. These tests pin the contract: the page says when it can answer (unsaved-ready), main sends a pending unacknowledged check again to that page only, and the page answers one check once.
Registering the check listener sends unsaved-ready; main sends a pending unacknowledged check to that page again, with its current reason, and the renderer ignores an id it already has. The changelog and window-frame say that a single close of a hung window waits for a second one, and that a page still busy about 7.5 s after a quit is destroyed with its pending save.
… twice The renderer forgets a check before it writes the app state, so a check main sends again on unsaved-ready during that write starts a second write.
A check main sends again during the exit write is then ignored instead of starting a second write.
reload, then a close and a quit joining it, then one report: the report of the quit's upgrade must not release the wait on the close's upgrade, whose late save may still be running. The upgrade carries a token that the page sends back with its report.
…angelog sections Each reason upgrade now carries a token that the page sends back with its report, so the quick report of a later upgrade no longer ends the wait on an earlier one whose late save is still running. The test harnesses pass the token through. The changelog entry moves under Unreleased, and the released devsuitup#540 line is left as it was published.
…e last upgrade reports With a close and a quit joined to a reload check, the close's report arrives first; an install arriving before the quit's report must join the question, not ask a second one.
…the last one reports Each upgrade link cleared the in-flight check when its own report arrived, so the first report cleared it while a later upgrade still waited, and a close, quit or install arriving then asked a second question. A count of pending upgrades on the joined check now clears it from the last link only.
22e6996 to
b3aaad6
Compare
|
Rebased with Two CHANGELOG Tree comparison Tests on the new head: the full suite has 14 failures, all environmental. They are the CLI-state canary and 13 test-pr "isolated" tests. 12 of those are the known sandbox failures. The 13th (4177, new on main with #591) fails with CI on b3aaad6: Test run 38371228767 success, Build & Release run 38371229326 success. |
devsuitup
left a comment
There was a problem hiding this comment.
Rebase verified at b3aaad6: git range-diff 80c87e3..22e6996 origin/main..b3aaad6 shows the nine commits with identical code; the only differences are CHANGELOG/docs context from main's new entries. git diff origin/main b3aaad6 touches the same 13 files as the approved 22e6996 (1141/80 vs 1143/80, the two lines being CHANGELOG). CI green on the head. Approval of 22e6996 carried over.
The close question asks with replace: true and may carry closeWith. With choice dialogs queued by the modal stack, replace now cancels the open one and puts the question at the front of the queue instead of resolving it as Cancel. Every close path (answer, replace, closeWith) unregisters from the stack. The tests follow the queue: a second choice dialog waits instead of answering null, and the native-dialog test waits for the idle question.
The close question asks with replace: true and may carry closeWith. With choice dialogs queued by the modal stack, replace now cancels the open one and puts the question at the front of the queue instead of resolving it as Cancel. Every close path (answer, replace, closeWith) unregisters from the stack. The tests follow the queue: a second choice dialog waits instead of answering null, and the native-dialog test waits for the idle question.
Stacked on #479 (9f9c57f, on main 421f77f). Until #479 is merged, this PR's diff includes its four commits; it will be rebased on main once #479 lands.
Merged with the reviewed #479.
unsaved-guard.jskeeps this PR'sREASON_RANK/entry.upgradeand adds #479's late-save wait: every upgrade (reloadtoclosetoquit) carries a token, the page sends it back withunsaved-check-reason-done(after the write for a reload it already approved, at once otherwise), and main waits for that upgrade's own report, bounded, released when the renderer is gone, and skipped when the check ended unanswered. While a joined check waits, it stays the one in flight, so a close arriving then joins it instead of showing a second question.will-prevent-unloadskips the reload of a check a close or quit joined (check.entry.reason). Inpublic/file-panel.jstheansweredReloadsbranch runs before the early return for an unknown id, andansweredReloads.addcomes beforeunsavedCheckResult.Changelog. This PR's entry is under Unreleased / Changed; the released #540 line under v0.0.93 is left as published.
Problem
The window's close button sits next to the strip's controls; a stray click ended the app and every session in it.
Change
The question. A window close (close button,
Alt+F4, the window manager's close) asks "Close Switchboard?" in the in-page choice dialog, with Cancel focused. On macOS it reads "Close the window?" and says Switchboard stays in the Dock: closing the window there does not quit, but its sessions still stop.closeevent asks the renderer with reason'close'instead of'quit'.'close'only, and only when the unsaved-edits dialog was not shown (Save or Discard there is already a decision to close).A busy page is asked, not skipped. A
'close'check has no bound before the page acknowledges it: a page busy when the close arrives shows the question once it is free. A page still starting (no listener yet) gets the check again once it registers one: the preload sendsunsaved-ready, main re-sends the pending unacknowledged check to that page with its current reason, and the renderer ignores an id it already has.Not asked again:
query-session-endapproves the quit first.unsaved-check-reason,'reload'<'close'<'quit'), the renderer closes the close question as a yes (or never shows it), and a check not yet acknowledged gets the quit's 2.5 s bound. The unsaved-edits dialog stays, as for any quit.A page that stops answering is destroyed in two cases only:
The user closes the window again:
A double click does nothing, neither delay destroys anything by itself, and a quit or an install never counts as a second close.
Electron reports it
unresponsivewhile the check is not acknowledged, or a ping is unanswered.Electron does not report a page that hung before the close, because the guard holds the close: a single-close journey timed out after 60 s (run 38070586412). So a single close of a hung window waits, and the second close is what ends it. The changelog entry of (window): close a window whose page stopped answering #540 says so.
A quit that the page never acknowledges goes ahead after 2.5 s; a page still busy about 7.5 s after the quit is destroyed with its pending working-set save ((window): close a window whose page stopped answering #540).
Native dialogs. While the page shows a native
alert,confirmorprompt, nothing is forced, and a quit's bound becomes a 5-minute ceiling.Focus. Cancel returns the focus to where it was.
Docs:
.ai/contexts/window-frame.md("Closing the window", "A window that stops answering", "A native dialog pauses the bounds"),viewer-panel.mdanddocs/e2e.md.Tests
test/unsaved-guard.test.js:'close', and has no bound before the ack;unresponsiveends an unacknowledged or pinged check, and leaves one being answered;test/dom-file-panel-unsaved-guard.test.js:test/dom-choice-dialog.test.js:closeWith.e2e/hung-window.spec.js(from (window): close a window whose page stopped answering #540) now closes twice: the hung window goes on the second close, and the journey exits in 7.8 s.e2e/close-window.spec.js: a real window close shows the question, Cancel keeps the window and a terminal that still runs commands, and Close exits with code 0; Quit from the menu exits with code 0 without asking.taskkillwithout/Fsends a close and gets the question./F, or an installer that escalates to it, ends the process without the quit cleanup, as before (documented in window-frame.md).🤖 Generated with Claude Code
Round 2026-10-11 (nit from the 13:07 re-check)
A check is forgotten only once answered, so one main sends again on
unsaved-readyduring the exit write is ignored instead of starting a second write. Tests first: 62a9064, red run 38147038161 (exactly the new test, all four test jobs; the rebased 6d61200 differs only by the #479 changelog line). Fix: 8e1f08f, green run 38147558889.