Skip to content

tests: run the qt widget tests on the process main thread - #13374

Open
tilladam wants to merge 2 commits into
slint-ui:masterfrom
tilladam:fix/qt-widgets-test-hang
Open

tilladam wants to merge 2 commits into
slint-ui:masterfrom
tilladam:fix/qt-widgets-test-hang

Conversation

@tilladam

@tilladam tilladam commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

On macOS, cargo test -p test-driver-rust --test widgets-qt hangs forever with 0% CPU. The qt style calls QStyle from the thread running the test, and QMacStyle instantiates AppKit controls for hit testing. On current macOS, AppKit's control layout goes through SwiftUI and blocks until it can run on the process main thread. libtest runs every test on a worker thread while the main thread waits for results, so the first NativeSlider hit test deadlocks:

NativeSlider::input_event
→ QMacStyle::hitTestComplexControl
→ QMacStylePrivate::cocoaControl
→ -[NSSlider initWithFrame:]
→ SwiftUICore ViewGraphRootValueUpdater.updateGraph()
→ _MovableLockSyncMain → pthread_cond_wait   (waits for the main thread, forever)

--test-threads=1 doesn't help: libtest still spawns a worker thread per test. CI never saw the hang because it kept the qt tests out of the regular steps with --skip filters and ran them in a separate pass with --test-threads=1.

This complements #13370, which runs tests/run_tests.sh offscreen for machines without a display. With a display, the offscreen platform is not in effect for plain cargo test, and this change also lets the tests exercise the real macOS Qt style.

Design

The widgets-qt target gets harness = false. Its generated modules register their tests with #[satchel::test], and the hand-written tests/widgets-qt.rs runs them through test_driver_lib::fork_harness: the parent forks one subprocess per test (marked with --exact <name>, the same convention cargo nextest uses), and each subprocess runs its single test on its own main thread with its own thread-local testing platform. Parallelism is preserved because every subprocess has its own main thread.

The harness is the one from tests/backends/tests/cases/harness.rs, moved into the driver library behind a fork-harness feature; the backends crate's qt and winit targets use it from there. It also honors #[ignore] on the forked trials.

The libtest CLI surface stays compatible: --skip=qt filters all tests out as before, --list prints the same names, and an --exact filter that names a test from another target reports zero matching tests instead of failing.

Testing

Everything measured on macOS arm64 (macOS 26.6, Qt with QMacStyle):

  • widgets-qt: 25/25 pass in ~7s, on both the macro and the build-time generation paths. Both previously hung on the first slider hit test.
  • Full test-driver-rust suite: 35/35 test binaries, 855 passed, 0 failed. The suite could not complete on this machine before.
  • -- --skip=qt:: (the CI invocation), -- --list, and cross-target -- --exact selection verified against the built binary.

CI

Unchanged. The serialized Qt pass still covers the interpreter driver's _qt tests, which reach into Qt for size hints from worker threads, and the tests/backends qt target, which the --skip=qt filter keeps off Windows. Moving those onto the fork harness and dropping the pass is a follow-up.

Follow-up

Run the interpreter driver's qt tests and the tests/backends qt target through the fork harness too, then drop the serialized Qt pass and the --skip=qt filters from CI.

@tilladam
tilladam force-pushed the fix/qt-widgets-test-hang branch 2 times, most recently from 908769c to fd2358b Compare September 19, 2026 07:04
@tronical

Copy link
Copy Markdown
Member

I think this is a neat idea and makes sense. Would be good though if either Leon or Olivier could review this approach - I think they may have more of an opinion on how to achieve this :)

@ogoffart

Copy link
Copy Markdown
Member

Does that mean we should remove the --skip=qt:: from the CI and the specific Qt pass?

@tilladam

Copy link
Copy Markdown
Collaborator Author

Yes, for widgets-qt the separate pass is obsolete. It was added in 2c53248 because Qt widgets can't live on different libtest worker threads. With this change every Qt test runs in its own subprocess on that process's main thread, so there is nothing left for --test-threads=1 to serialize.

Two things to decide before dropping it:

  • The run_qt gate also skips the Qt tests when no Qt files changed. Folding them into the main run gives that CI time back. Happy either way.
  • The root-workspace step's --skip=qt:: covers any other qt:: tests outside test-driver-rust; I'd check those before removing the flag there too.

I can do the CI cleanup in this PR or as a follow-up, whichever you prefer.

@ogoffart

Copy link
Copy Markdown
Member

The run_qt gate also skips the Qt tests when no Qt files changed. Folding them into the main run gives that CI time back. Happy either way

Yeah, i think it won't make a difference since they are built either way and running them doesn't take any time.

The root-workspace step's --skip=qt:: covers any other qt:: tests outside test-driver-rust; I'd check those before removing the flag there too.

indeed, maybe the interpreter test needs a similar trick

@tilladam
tilladam force-pushed the fix/qt-widgets-test-hang branch from fd2358b to ea5410e Compare September 29, 2026 07:26
@tilladam

Copy link
Copy Markdown
Collaborator Author

Done in ea5410e, on top of a rebase onto master: the --skip filters, both serialized Qt steps and the run_qt input are gone.

One correction to my previous comment: the integration step used --skip=qt without the colons, which also matched the interpreter's _qt test names, so those were skipped too. Measured locally on macOS with a display: the 26 interpreter qt tests pass on libtest worker threads with default parallelism, six runs in a row, no crash. They only evaluate the test property and never dispatch input, so no QStyle hit test runs. The rebased widgets-qt target passes 29/29 through the fork harness.

@ogoffart

Copy link
Copy Markdown
Member

They only evaluate the test property and never dispatch input, so no QStyle hit test runs.

This would still need to reach into Qt for things like default size and stuff like that which may cause problems as they still use non-trhead-safe APIs

@LeonMatthes LeonMatthes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The solution itself is fine, but we already have a better way to do this statically under tests/backends/cases/harness.rs which requires less code generation at build time.

Comment thread tests/driver/rust/build.rs Outdated
Comment on lines +175 to +178
fn write_main_thread_harness(
output: &mut dyn Write,
tests: &[MainThreadTest],
) -> std::io::Result<()> {

@LeonMatthes LeonMatthes Sep 29, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of generating a full harness from text every time, can we just ship the harness as a file under driver/rust/tests/widgets-qt that just uses include! to collect the tests?
(or use satchel for test collection).

That makes this a normal editable Rust file again, instead of generating it at build time.

Note: We have exactly this setup under /tests/backends/ for the qt and winit tests which must also run on the main thread with custom init code. There the code lives under cases/harness.rs.

Ideally we should just reuse that approach and ideally the code.

Note: if we use satchel, we might not even have to adjust the generated code for the tests, as we can keep using #[test] for test collection and we just have to enable our own harness.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8366347. The harness is now a normal file, and it's the one from tests/backends: cases/harness.rs moved into test_driver_lib::fork_harness behind a fork-harness feature, and both the backends targets and widgets-qt call it from there. The generated qt modules use #[satchel::test], so build.rs only swaps the attribute and wraps the Result body; tests/widgets-qt.rs is hand-written and calls test_main(satchel::get_tests!(), ...).

Measured locally on macOS: widgets-qt 29/29 on both the macro and build-time paths, test-backends qt and winit 11/11 each through the shared code, --list, --skip=qt and a cross-target --exact behave as before.

@tilladam
tilladam force-pushed the fix/qt-widgets-test-hang branch from ea5410e to 8366347 Compare September 29, 2026 09:25
@tilladam

Copy link
Copy Markdown
Collaborator Author

Two updates:

  • The CI cleanup is out again. Dropping --skip=qt also unskipped the tests/backends qt target on Windows, where Qt isn't built, and it panics there with "Qt platform requested but Slint is compiled without Qt support". With Olivier's point that the interpreter's qt tests still reach into non-thread-safe Qt APIs for size hints, the serialized pass should stay until those are on the fork harness too. That's a follow-up; this PR keeps CI as it is.
  • The harness is now the shared, checked-in one Leon asked for, see the thread. History: 75ef07e is the original change rebased onto master, 8366347 the rework on top.

The macOS cpp_test_driver (beta) failure is the same library 'slint_cpp' not found link error master has had since yesterday.

On macOS the qt style creates AppKit controls, and AppKit deadlocks
on any thread but the process main thread. libtest runs every test
on a worker thread, so the widgets-qt driver tests hung forever in
NativeSlider's QMacStyle hit test.

Give the widgets-qt target its own harness, following the pattern of
the test-backends crate: libtest-mimic forks one subprocess per test,
and each subprocess runs its single test on its own main thread with
its own thread-local testing platform. CI's '--skip=qt::' filter and
'--list' behave as before.
The widgets-qt target no longer generates its harness at build time.
The generated qt modules register their tests with #[satchel::test], and
a hand-written tests/widgets-qt.rs runs them through
test_driver_lib::fork_harness, which is the harness from
tests/backends/tests/cases/harness.rs moved into the driver library
behind a fork-harness feature. The backends crate's qt and winit targets
use it from there.

The shared harness also honors #[ignore] on the forked trials.
@tilladam
tilladam force-pushed the fix/qt-widgets-test-hang branch from 8366347 to 6917272 Compare September 30, 2026 15:16
r"
#[rust_analyzer::skip]
#[satchel::test] {} fn t_{}() {{
(|| -> ::std::result::Result<(), ::std::boxed::Box<dyn ::std::error::Error>> {{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: why do we use a closure here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snippets use ?, so they need a body that returns Result, as in the #[test] branch below it.
satchel registers tests as fn() (TestFn in satchel 0.3) and doesn't wrap other return types the way libtest does.
So the closure gives the snippet a Result-returning scope, and .unwrap() turns an Err into the panic satchel counts as a failure.

I can make it a named inner fn body() -> Result<…> plus body().unwrap() if that reads better. It does the same thing.

@tilladam
tilladam requested a review from LeonMatthes October 3, 2026 20:52

This branch has not been deployed

No deployments
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.

4 participants