Skip to content

fix(worker): pin queued slots to main-thread @Slot dispatcher#31

Merged
nelsonduarte merged 6 commits into
mainfrom
fix/compress-freeze-py314-win
May 5, 2026
Merged

fix(worker): pin queued slots to main-thread @Slot dispatcher#31
nelsonduarte merged 6 commits into
mainfrom
fix/compress-freeze-py314-win

Conversation

@nelsonduarte
Copy link
Copy Markdown
Owner

@nelsonduarte nelsonduarte commented May 5, 2026

Summary

  • app/worker.py:run_task — replace lambda-based QueuedConnection slots with a _Dispatcher(QObject) parented on the main thread, with @Slot-decorated on_progress / on_finished / on_error relays. 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-arg Signal().emit as a Python callable (which silently TypeErrored because clicked emits a bool).

Root cause

On Python 3.14 + PySide6, QueuedConnection slots 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_toast all ran on the worker; QWidgets created there took worker-thread affinity, and reparenting them to the main-thread layout produced QObject::setParent: Cannot set parent, new parent is in a different thread warnings 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, so QueuedConnection posts 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 thread and QThread: Destroyed while thread is still running. Windows fast-fails with STATUS_STACK_BUFFER_OVERRUN 0xC0000409. Both crashes are caused by the same bug; the fix resolves both.

Test plan

  • Smoke test (_FakeRunner through run_task + threading.get_ident() check inside _on_done):
    • Windows + Py3.14: old code → 0xC0000409; fix → on_main_thread=True
    • Ubuntu 26.04 + Py3.14.4 (WSLg): old code → Aborted: Timers cannot be stopped from another thread; fix → on_main_thread=True
  • Live GUI on Windows + Py3.14: open PDF → Compress (pipeline mode) → no freeze
  • Live GUI on Ubuntu Py3.14 via WSLg — pending manual confirmation
  • Spot-check OCR and PDF→image converters (also use run_task) — should benefit from the same fix

🤖 Generated with Claude Code

nelsonduarte and others added 5 commits May 4, 2026 19:15
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>
Comment thread app/worker.py Fixed
Comment thread app/worker.py Fixed
Comment thread app/worker.py Fixed
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>
@nelsonduarte nelsonduarte merged commit ff70fa1 into main May 5, 2026
3 checks passed
@nelsonduarte nelsonduarte deleted the fix/compress-freeze-py314-win branch May 5, 2026 11:54
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.

2 participants