Skip to content

fix(gui): stop the UI rebuild from destroying the registry's windows#40

Merged
donislawdev merged 1 commit into
masterfrom
fix/gui-teardown-crashes
Jul 24, 2026
Merged

fix(gui): stop the UI rebuild from destroying the registry's windows#40
donislawdev merged 1 commit into
masterfrom
fix/gui-teardown-crashes

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

What and why

Two swallowed GUI teardown crashes from a real field crashes.ndjson (both swallowed/debug):

  • gui.windowsTclError: bad window path name ".!toplevel2" in _save_geometry
  • gui.labelsTclError: invalid command name ".!label" in _resize

They turned out to share one cause, and the trigger was not closing a window but a language switch: App._build_ui rebuilds the UI by destroying every child of the root window, and a Toplevel is a child of the root. The open panel windows (e.g. Settings) died there, so the windows.rebuild() that follows ran close() on windows that no longer existed.

That cost two things, not one:

  1. the recorded TclError per switch, and
  2. the geometry was never saved — after a language switch a window you had moved or resized came back where it was last closed, not where you had just put it. That is a real behaviour bug the crash log was hiding, now fixed too.

The label crash was not a one-off teardown race either: wrapping_label binds <Configure> on the container, which outlives the label, and nothing unbinds it. Every rebuild leaves one more dead handler behind, each recording on every subsequent resize.

Changes

  • App._build_ui skips the Toplevels the registry owns (new WindowManager.toplevels()); window lifecycle belongs to the registry, not the rebuild.
  • PanelWindow._save_geometry no-ops when the window is already gone (winfo_exists).
  • bind_wraplength._resize no-ops when the label is gone.
  • on_close already closed the windows before persisting UI state (so geometry survives quit); that ordering had no test and now has one.
  • tests/fake_tk.py made honest about destruction: destroy() kills children and leaves the widget dead, winfo_exists() is real, and configure()/geometry() raise TclError afterwards — the way Tk does. Without this none of the guards could be tested, and a winfo_exists() guard would read as "destroyed" for every widget on the fake (because W.__getattr__ answers any unknown attribute with a no-op None).

Reviewer notes

  • All four new tests verified by mutation (PROJECT_NOTES rule 5): each guard was removed in turn and the test that claims to catch it went red. That mutation pass is also what caught a rule-4 slip — an added duplicate windows.close_all() in on_close (the call was already there); the duplicate was removed and the test now guards the real call and its ordering.
  • Not fixed here, deliberately: _build_ui also re-adds root.bind("<Configure>", self._on_root_configure, add="+") on every rebuild without removing the old one, so that handler multiplies the same way. It holds the App, not a dead widget, so it costs duplicated work per resize rather than crash records. Noted in CHANGELOG-INTERNAL.md.
  • Numbers measured, not asserted: the two crash records were confirmed against the actual crashes.ndjson before and the mechanism reproduced.

Checklist

  • python -m pytest tests passes locally. (659 passed; run elevated, so no admin-only deselects needed.)
  • New behaviour has tests (see tests/ for the style). Four added, all mutation-verified.
  • UI text goes through i18n keys, with both lang/en.json and lang/pl.json updated. (No new UI text in this change — no lang/ edits needed.)
  • User-facing changes noted in CHANGELOG.md; technical ones and new tests in CHANGELOG-INTERNAL.md, under [Unreleased].
  • Commits follow Conventional Commits (type(scope): summary).
  • No version bump — the owner closes a version via VERSION.txt.

🤖 Generated with Claude Code

Two swallowed teardown crashes from a field crash log had one cause: App._build_ui
rebuilds by destroying every child of the root window, and a Toplevel is a child of
the root. The open panel windows died there, so the rebuild that follows a language
switch ran close() on windows that no longer existed - recording TclError("bad window
path name") and, less visibly, dropping the geometry the user had just set (the window
came back where it was last CLOSED).

- _build_ui skips the Toplevels the registry owns (new WindowManager.toplevels)
- PanelWindow._save_geometry no-ops when the window is already gone
- bind_wraplength._resize no-ops when the label is gone: the <Configure> binding
  lives on the CONTAINER, which outlives the label, and nothing unbinds it - so this
  was one dead handler per rebuild, each recording on every resize, not a one-off race
- tests/fake_tk.py now models destruction (dead widget, real winfo_exists, TclError
  from configure/geometry); without it no test could catch either bug, and a
  winfo_exists guard would have read as "destroyed" for every widget on the fake

All four new tests verified by mutation: each guard removed in turn, each test goes
red. Full suite green (659 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 9b61bc6 into master Jul 24, 2026
8 checks passed
@donislawdev
donislawdev deleted the fix/gui-teardown-crashes branch July 24, 2026 16:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant