Skip to content

Fix Tk 9 crash on startup and generation-thread callbacks - #28

Open
ddisisto wants to merge 3 commits into
socketteer:mainfrom
ddisisto:fix-tk9-and-generation-threading
Open

Fix Tk 9 crash on startup and generation-thread callbacks#28
ddisisto wants to merge 3 commits into
socketteer:mainfrom
ddisisto:fix-tk9-and-generation-threading

Conversation

@ddisisto

@ddisisto ddisisto commented Aug 9, 2026

Copy link
Copy Markdown

Three provider-agnostic fixes found while getting loom running on a current Linux distro. No new dependencies, no behaviour changes beyond the bugs described.

1. Variable.trace was removed in Tk 9

Tcl/Tk 9 dropped the deprecated trace variable subcommand, which is what Python's Variable.trace(mode, callback) shells out to. On any Tk 9 build, every var.trace('w', ...) call raises:

_tkinter.TclError: bad option "variable": must be add, info, or remove

There are 10 of these across components/dialogs.py, components/modules.py, components/templates.py and view/panes.py, and they fire while building panes, so the app can't open a pane at all on a distro that has moved to Tk 9.

Replaced with the trace_add('write', ...) form. That has been available since Python 3.6 and passes the callback the same (name, index, mode) arguments, so no call sites needed changing.

2. Generation-thread callbacks never reached the main thread

Generation runs in a worker thread, and two of its callbacks end up touching Tk:

delete_failed_nodes called self.tree_updated(...) directly — exactly what the # DO NOT CALL FROM THREAD: self.tree_updated() comment two functions above it warns against. It runs the whole view refresh on the worker thread. Under Tk 9 that aborts the process outright:

Unknown display passed to Tk_CreateErrorHandler

So any failed generation killed the app rather than just dropping the failed nodes.

post_generation avoided that by going through self.app.event_generate("<<NewNodes>>", when="tail") instead. But Tk queues virtual events per-thread, so an event generated from a worker thread is never delivered to the main thread's event loop, and edit_new_nodes simply never ran. I checked this empirically rather than assuming: firing the event from a worker thread produces no dispatch, while the identical call on the main thread dispatches normally. It's a silent no-op rather than a crash, which is presumably why it went unnoticed.

Both now go through a small queue.Queue that the main thread drains from an after() timer (call_on_main_thread / drain_main_thread_queue), which is the conventional way to hand work from a worker thread into tkinter. Callbacks that raise are caught and traced so one bad callback doesn't stop the drain loop.

Verified by driving default_generate from worker threads against a real API and asserting both callbacks execute on MainThread.

3. retry's on_failure result was discarded

In util/util.py, the retry decorator's failure branch called on_failure(*args, **kwargs) without returning it. Every exhausted retry chain therefore returned None instead of the handler's intended ("", None), and the caller's unpack blew up with:

TypeError: cannot unpack non-iterable NoneType object

which buries whatever error actually exhausted the retries. One-line fix: return on_failure(...).


Tested on Python 3.11 with Tcl/Tk 9.0 on Arch/Manjaro.


I have some further work sitting in a branch that I've deliberately kept out of this PR, since it's more opinionated than a bug fix:

  • Dependency unpinning. The current pins don't install on Python 3.11+ — pandas==1.3.3 pulls a numpy that won't compile against modern toolchains, and tk==0.1.0 in requirements.txt is an unrelated PyPI package rather than the tkinter bindings (those ship with CPython).
  • A uv / pyproject.toml setup, as an alternative to the requirements.txt flow.
  • OpenRouter provider support, including a completions-endpoint variant so a story prompt gets continued rather than replied to.

Would you welcome any of those as follow-up PRs, or would you rather keep the project as it stands? Completely fine either way — happy to just leave these three fixes here if the project is in maintenance mode.

🤖 Generated with Claude Code

ddisisto and others added 3 commits August 9, 2026 17:56
Tcl/Tk 9 dropped the deprecated `trace variable` command, so every
`var.trace('w', ...)` raises TclError: bad option "variable" and the app
cannot open a pane. Use the trace_add('write', ...) form, which has been
available since Python 3.6 and passes callbacks the same arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generation runs in a worker thread, and two of its callbacks reach Tk:

delete_failed_nodes called tree_updated() directly, exactly what the
comment two functions above it warns against, so any failed generation
ran the view refresh on the worker thread. Under Tk 9 that aborts the
process with "Unknown display passed to Tk_CreateErrorHandler".

post_generation avoided that with event_generate("<<NewNodes>>"), but
virtual events are queued per-thread, so an event generated off the main
thread is never delivered and edit_new_nodes never ran.

Replace both with a queue the main thread drains on a timer, which is the
usual way to get work out of a worker thread and into tkinter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The retry decorator called on_failure(*args, **kwargs) without returning
its value, so an exhausted retry chain returned None rather than the
handler's intended ("", None). Callers that unpack the result then failed
with "cannot unpack non-iterable NoneType object", which hides whatever
error actually exhausted the retries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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