Skip to content

Feat/long operation more sites - #578

Open
azfoo wants to merge 20 commits into
TimeLineAnnotator:devfrom
azfoo:feat/long-operation-more-sites
Open

Feat/long operation more sites#578
azfoo wants to merge 20 commits into
TimeLineAnnotator:devfrom
azfoo:feat/long-operation-more-sites

Conversation

@azfoo

@azfoo azfoo commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Async architecture initiative

Fixes PR #538's wait_for_signal reentrancy segfault (WebEngine + processEvents()), then follows the same pattern through the rest of the codebase: three categories of "faking async" replaced with the real thing.

1. Reentrancy fix: QMediaPlayer playback vs. QWebEngineView

QtPlayer.wait_for_signal blocked the GUI thread on a nested QEventLoop().exec() — safe until a QWebEngineView (YouTube player, MusicXML score rendering) has pending Chromium IPC work in-process, at which point reentering the event loop can segfault.

  • QtAudioPlayer/QtVideoPlayer now run their QMediaPlayer/QAudioOutput on a dedicated worker QThread, blocking the GUI thread on a plain threading.Event instead — a raw OS wait can't reenter the GUI thread's dispatcher.
  • QtVideoPlayer's first version had a real bug automated tests missed entirely: pairing the worker-thread player with the GUI-thread QVideoWidget via setVideoOutput() looked fine (no crash, correct state transitions, even a real-windowing repro script) but silently dropped every video frame cross-thread. Manual visual testing in the running app caught it — the picture was black. Fixed by pairing with a same-thread QVideoSink and relaying frames to the widget via a GUI-thread FrameRelay.
  • Two more manual-testing-only catches: position polling turning into a blocking cross-thread round-trip caused real, sustained lag under CPU contention (fixed by pushing position from the worker instead of polling-and-blocking for it), and un-backpressured frame relaying caused video to drift further behind audio the longer playback ran (fixed by relaying only the latest frame on a fixed-rate timer).
  • Extended to CLI, which still used the unfixed QtPlayer directly and was equally exposed (CLI supports YouTube and MusicXML score import).
  • QtAudioPlayer/QtVideoPlayer's near-total duplication (shared once CLI became a third caller) extracted into WorkerThreadPlayer/EngineWorker/EngineRequests base classes.

2. Real background threading

  • tilia/ui/background_task.py::run_in_background — new generic helper (QThread + worker QObject + GUI-thread relay QObject, since a signal connected to a plain callable doesn't auto-queue across threads).
  • export_audio and AudioWaveTimeline's amplitude computation now run on it instead of blocking the GUI thread.

3. Chunked progress feedback

  • @long_operation (from Feat/long operation feedback #538) applied to CSV/MusicXML import, deserialize_timelines, open_tla, export_audio, and audiowave timeline construction — all previously silent or GUI-freezing during large imports/opens.
  • The pump strategy now detects any QWebEngineView in-process, not just the YouTube player, before deciding whether processEvents() is safe.

azfoo added 19 commits August 26, 2026 16:23
…_for_signal reentrancy

QtPlayer's wait_for_signal blocks the calling thread on a nested
QEventLoop -- unsafe once a QWebEngineView (musicxml_to_svg, YouTube
player) has pending Chromium IPC work on the GUI thread.

QtAudioPlayer now runs its QMediaPlayer/QAudioOutput on a dedicated
worker QThread (audio_worker.py). Synchronous calls (load/stop/exit)
hand off to the worker via a Completion (threading.Event) the GUI
thread blocks on -- a raw OS thread wait can't reenter the GUI
thread's event dispatcher, so it's safe regardless of pending
WebEngine IPC work.

Position is pushed from the worker to a GUI-thread PositionRelay
(qtplayer.py) on its own timer, rather than the GUI thread
polling-and-blocking for it on every UPDATE_INTERVAL tick: a blocking
round-trip in that hot loop would starve the GUI thread's message
pump for the entire duration of playback, and unlike wait_for_signal's
QEventLoop (which keeps servicing the OS message queue while
functionally stuck), threading.Event.wait() pumps nothing at all --
enough to trip Windows' hung-window watchdog under CPU contention.

Verified with a new TestPlayerWithWebEngineView regression test
(load/play/stop local audio with a live QWebEngineView present).
This skip predates wait_for_signal itself -- it was added alongside a time.sleep()-based freeze workaround, which wait_for_signal replaced without the marker being revisited. These tests are audio-only (QtAudioPlayer), which after the worker-thread fix no longer depends on wait_for_signal's timing-sensitive nested event loop at all. Verified locally with CI=true, no flakiness; real
confirmation needs an actual CI run.

Also removes the adjacent conservative_player_stop fixture: unused by any test, and references player.SLEEP_AFTER_STOP, an attribute that no longer exists on any Player implementation.
App.on_close() never called player.destroy() before exiting, meaning
the app's normal quit path has been leaking QtAudioPlayer's worker
thread since it started running on one. Fixed via a Post.UI_EXIT
listener (App.on_ui_exit) rather than inlining the call in on_close():
tests/test_app.py::TestSaveFileOnClose intercepts that post to test
close/save-confirmation logic without a real exit, and relies on it
being a safe no-op -- inlining the call would break that isolation.
Extends the wait_for_signal reentrancy fix (previously QtAudioPlayer
only) to video. QtVideoPlayer was left out originally because
QMediaPlayer's video rendering was believed to require the GUI thread
-- true for QVideoWidget itself, but conflated with QMediaPlayer
needing the same thread as that widget.

The worker-thread player can't be paired with the GUI-thread
QVideoWidget via setVideoOutput() directly, though: that looks fine
under automated testing (no crash, correct state transitions) but
silently drops all frame delivery cross-thread (Qt logs
"QMetaMethod::invoke: Unable to invoke methods with return values in
queued connections" at pairing time) -- audio plays, picture stays
black. Only manual visual testing in the running app caught it.

Fixed by pairing the player with a same-thread QVideoSink instead, and
relaying frames to the widget's own sink via a GUI-thread FrameRelay
QObject (video_worker.py) -- same queued-connection-to-a-real-QObject
pattern as _ResultRelay/PositionRelay. Frames are pushed at a fixed
rate off a cached "latest frame" rather than relayed on every raw
decode: relaying every frame directly queues one delivery each, and if
the GUI thread ever falls behind (e.g. contending with an
unaccelerated QWebEngineView), it has to drain that backlog in order,
so the picture visibly drifts further behind audio the longer that
persists -- also only caught by watching actual playback, not by any
automated check.

video_worker.py otherwise mirrors audio_worker.py's pattern (position
pushed via PositionRelay instead of the GUI thread polling-and-blocking
for it, worker-thread QTimers stopped from their own thread on exit to
avoid cross-thread QObject destruction warnings).

Verified with TestVideoPlayer/TestVideoPlayerWithWebEngineView
(load/play/stop local video, with and without a live QWebEngineView),
plus manual confirmation in the running app that video actually
renders, stays in sync with audio, and the app stays responsive.
CLI's players were still the unfixed QtPlayer: get_player_class mapped
"audio" straight to it, and CLIVideoPlayer subclassed it directly. CLI
also supports YouTube (CLIYoutubePlayer talks to the YouTube Data API
over HTTP rather than embedding a page, so it isn't itself a
QWebEngineView) and score import (musicxml_to_svg, a real
QWebEngineView), so the same reentrancy hazard this session's
QtAudioPlayer/QtVideoPlayer fixes addressed was still fully reachable
there.

CLIVideoPlayer now subclasses QtAudioPlayer directly (MEDIA_TYPE=
"video") instead of QtPlayer -- QMediaPlayer plays a video file's
audio track fine with no video output set, and CLI has no GUI to show
a picture in anyway. get_player_class's "audio" entry now maps to
QtAudioPlayer.

QtPlayer had no callers left after this and has been removed, along
with wait_for_signal's docstring reference to it.

New tests: test_load_local_video (CLI had no coverage at all for
loading a real local video file) and
test_load_media_with_webengineview_alive (regression coverage for the
exact hazard this fixes, mirroring the GUI's WebEngineView player
tests).
New in qtplayer.py:
- WorkerThreadPlayer: the shared Player subclass __init__ wiring and  _engine_* implementations. Subclasses set _worker_cls and override _extra_setup/_connect_extra/_on_load_success/_extra_exit_cleanup hooks for anything beyond it (QtVideoPlayer's widget/FrameRelay). _requests_cls defaults to EngineRequests since audio and video have no reason to diverge there.
- EngineWorker/EngineRequests: the same treatment one level down, for audio_worker.py/video_worker.py's worker-thread classes. AudioEngineRequests/VideoEngineRequests were literally identical, so both now just use EngineRequests directly rather than two empty subclasses. EngineWorker's _extra_worker_setup/_extra_worker_exit_cleanup hooks are named distinctly from WorkerThreadPlayer's GUI-thread-side hooks of a similar shape, to avoid two same-named hooks with different thread affinity living in unrelated classes.
- Completion moved here too (was identically copy-pasted in both worker modules).

qtaudio.py/qtvideo.py and audio_worker.py/video_worker.py now hold only what's genuinely audio- or video-specific: qtaudio.py is a 16-line subclass; video_worker.py keeps the QVideoSink pairing and the frame-relay backpressure timer. VideoEngineWorker/AudioEngineWorker are siblings of EngineWorker, not one subclassing the other -- video isn't a kind of audio, they're two independent uses of the same underlying QMediaPlayer/worker-thread machinery, and video inheriting from something named Audio would be a misleading hierarchy that also risks silently absorbing audio-only behaviour if AudioEngineWorker ever needs any.

Also fixed real type-annotation bugs in base.py's abstract _engine_* methods, surfaced while reviewing the seam between qtplayer.py and the base class: _engine_load_media was declared -> None but every implementation returns bool and load_media() actually consumes that value; _engine_exit was declared -> float for no reason anything ever needed; _engine_stop had no return annotation at all despite one implementation returning an unused value. Propagated the same corrections to CLIYoutubePlayer's stubs and YouTubePlayer, and
tightened wait_for_signal's own typing with ParamSpec so the decorator preserves the wrapped function's actual parameters instead of collapsing them to *args/**kwargs: Any. _worker_cls/_requests_cls are now type[EngineWorker]/type[EngineRequests] instead of bare type.

CI caught a real bug this introduced: WorkerThreadPlayer.__init__ constructed QMediaDevices() before calling _extra_setup(), reversing QtVideoPlayer's original order (its QVideoWidget used to be built first). That inversion caused a real, reproducible crash (Fatal Python error: Aborted, inside long_operation.py's processEvents() pump) when
loading a local video -- confirmed by bisecting against this same commit with and without the reorder, not just inferred. Fixed by running _extra_setup() before QMediaDevices() again, restoring the original order for QtVideoPlayer (a no-op for QtAudioPlayer, which has
no widget).
_change_player_type constructs the new player before destroying the old one, for exception safety -- fine normally, but a live QWebEngineView and a live QMediaPlayer deadlock on macOS if they coexist even briefly, which was already handled when leaving YouTube (destroy old + flush DeferredDelete before constructing the new player) but not when *entering* it: the new YouTubePlayer's QWebEngineView used to get constructed while the old QtAudioPlayer/
QtVideoPlayer -- worker thread and its QMediaPlayer included -- was still fully alive.

That coexistence window used to be a same-thread, microseconds-long sequence under the old synchronous player design, and CI passed on it before. Moving QtAudioPlayer/QtVideoPlayer to a worker thread turned `player.destroy()` into a real blocking wait for that thread, making the window long enough to reliably deadlock in CI: a video-then-YouTube test hung for the full 30-minute CI timeout, with orphaned QtWebEngineProc processes left behind at cleanup.

Fixed by taking the same destroy-and-flush path when entering YouTube too, not just leaving it.
The test waited for destination.exists() before reading the file back, but a file can exist before soundfile.write() (running on run_in_background's worker thread since TimeLineAnnotator#538's follow-up work) has finished flushing all its data -- production code doesn't have this problem (export_audio's on_done, and so LongOperation.DONE, only fires once the worker-thread write has fully returned), but this test's own synchronization was weaker than the signal already available to it.

Flaky on CI (different exact truncated lengths each run, always this one test); the sibling test_reports_progress_via_long_operation already had a comment explaining exactly this and waited for DONE correctly -- this test just wasn't doing the same thing. Fixed to match.
wait_for_signal's nested QEventLoop.exec() still services the worker thread's other pending Qt events, including _position_timer's own queued timeout. That let _emit_position fire reentrantly mid-wait, which could race against concurrent teardown/GC and crash natively.

Pause the position timer for the duration of each nested wait via _position_timer_paused(), wrapping the entire body of load/stop/unload rather than just _do_stop() so a nested _do_stop() call (from load()) doesn't re-arm the timer before the outer wait finishes.
@azfoo
azfoo force-pushed the feat/long-operation-more-sites branch from 898a332 to 88b0063 Compare August 26, 2026 15:09
@azfoo azfoo closed this Aug 26, 2026
@azfoo azfoo reopened this Aug 26, 2026
@azfoo azfoo closed this Aug 27, 2026
@azfoo azfoo reopened this Aug 27, 2026
test_writes_requested_segment failed once in CI with soundfile.LibsndfileError: "System error" opening the destination for read.

Root-caused, not just papered over: ruled out an application-level write failure first -- the autouse print_errors listener prints any Post.DISPLAY_ERROR, and nothing appeared in the captured log for that test, so export_audio's on_error path (which also posts
LongOperation.DONE, same as success) never fired. The remaining explanation matches a documented Windows phenomenon: an AV/indexer minifilter can hold a brief scan lock on a file even after the writer's own close() returns, so an immediate reopen from another
thread can hit a sharing violation.

Confirmed this is test-only, not an app-facing bug: on_export_audio (base.py) fires export_audio and returns; its on_done/on_error callbacks only post LONG_OPERATION and never read the file back. No production path re-opens an exported file within milliseconds of
writing it the way this test's own verification step does.

Fix: assert no error was displayed and the file exists before opening it (so a real write failure would surface distinctly), and retry the read with a short bounded backoff to absorb the lock-release delay.
@azfoo

azfoo commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

(sorry about the closing and reopening: it was a hacky way to trigger git actions after it went down)

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