fix(worker): pin queued slots to main-thread @Slot dispatcher#31
Merged
Conversation
In pipeline mode, the tool result lives in a temp file until the user chooses where to save it. Add a prominent "Save as..." button to the success toast so the action is discoverable — without it, users assume the result is already saved. BasePage emits a new pipeline_save_requested signal on click; MainWindow connects it to _save_pipeline. Also drops draft launch_texts.md (Reddit / HN / Product Hunt copy) for upcoming launch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-race crash The auto-update worker QThread was lazy-importing app.updater, which transitively pulls in urllib.request -> http.client -> ssl. On Python 3.14 (Windows), importing those heavy modules from a non-main thread while the main thread is still building Qt widgets produces a fatal access-violation. Move the import to the main thread before the worker starts; the worker only calls the already-imported function. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In pipeline mode (save_callback provided), the toast is the only UI that surfaces the unsaved-state save action; auto-hiding it after 8 s risks the user missing the chance to click. In plain "operation done" mode the toast is just confirmation, so the original auto-hide stays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 4 s QTimer.singleShot in _on_text_copied resets the selection status label. If the user closes the viewer tab within that window, the C++ widget is destroyed and the lambda crashes when accessing _sel_status. PySide6 has no QPointer, so use shiboken6.isValid() to bail early when the widget is dead — same pattern as the toast timer in base.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On Windows + Python 3.14, connecting `runner.finished` to a Python lambda with `Qt.QueuedConnection` was not enough: PySide6 routed the queued metacall to the sender's thread (the worker) instead of the main thread, because the lambda has no QObject thread affinity. This made `_final → _on_done → _pipeline_success → _show_toast` all run on the worker; the QWidgets created in `_show_toast` took worker-thread affinity, and reparenting them to the main-thread BasePage layout emitted "QObject::setParent: Cannot set parent, new parent is in a different thread" and corrupted subsequent signal dispatch — `pipeline_done.emit()` returned without invoking its slot and the app hung (`STATUS_STACK_BUFFER_OVERRUN 0xC0000409`). Linux dispatched lambdas to the main thread by chance, hiding the bug there. Define a tiny `_Dispatcher(QObject)` parented to `parent` (main-thread BasePage) with `@Slot`-decorated relays for progress / finished / error. QueuedConnection now correctly routes to the dispatcher's thread (main), so `_final` and the user-supplied callbacks always run on the main thread. Also fix the toast "Save as..." button: `clicked` emits `bool` while `pipeline_save_requested = Signal()` rejects positional args, so `btn.clicked.connect(self.pipeline_save_requested.emit)` silently TypeErrored on every click. Switch to native signal-to-signal forwarding `btn.clicked.connect(self.pipeline_save_requested)`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL flagged `_self` as the first parameter on the @slot methods (rule: "First parameter of a method is not named 'self'"). The reason for `_self` was to avoid shadowing the closure variables `on_finished` / `on_error` captured from run_task's parameters. Rename the methods to `relay_progress` / `relay_finished` / `relay_error` so `self` can be used conventionally. Behaviour is unchanged; smoke test on Ubuntu 26.04 + Py3.14.4 still passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
app/worker.py:run_task— replace lambda-basedQueuedConnectionslots with a_Dispatcher(QObject)parented on the main thread, with@Slot-decoratedon_progress/on_finished/on_errorrelays. Fixes the pipeline-mode compress freeze on Python 3.14 across both Windows and Linux.app/base.py:_show_toast— switch the "Save as..." button to native signal-to-signal forwarding (btn.clicked.connect(self.pipeline_save_requested)) instead of passing a no-argSignal().emitas a Python callable (which silently TypeErrored becauseclickedemits abool).Root cause
On Python 3.14 + PySide6,
QueuedConnectionslots whose receiver is a plain Python lambda are dispatched on the sender's thread (worker), not the main thread, because the lambda has no QObject thread affinity. So_final → _on_done → _pipeline_success → _show_toastall ran on the worker; QWidgets created there took worker-thread affinity, and reparenting them to the main-thread layout producedQObject::setParent: Cannot set parent, new parent is in a different threadwarnings and corrupted signal dispatch from the page (pipeline_done.emit()returned without invoking its slot, app hung).@Slot-decorated methods on a parented QObject give Qt the thread information it needs, soQueuedConnectionposts the metacall to the right event loop.Cross-platform note
Previously believed to be Windows-only because Linux developers were running Py3.13 (Debian default). Validated on Ubuntu 26.04 + Python 3.14.4 (WSLg): old code aborts with
Timers cannot be stopped from another threadandQThread: Destroyed while thread is still running. Windows fast-fails withSTATUS_STACK_BUFFER_OVERRUN 0xC0000409. Both crashes are caused by the same bug; the fix resolves both.Test plan
_FakeRunnerthroughrun_task+threading.get_ident()check inside_on_done):0xC0000409; fix →on_main_thread=TrueAborted: Timers cannot be stopped from another thread; fix →on_main_thread=Truerun_task) — should benefit from the same fix🤖 Generated with Claude Code