Skip to content

fix(pj_base): store the watchdog handle before the watchdog can read it - #199

Closed
GNERSIS wants to merge 2 commits into
mainfrom
fix/watchdog-arm-race
Closed

GNERSIS wants to merge 2 commits into
mainfrom
fix/watchdog-arm-race

Conversation

@GNERSIS

@GNERSIS GNERSIS commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

JobControl::armWatchdog (pj_base/src/source/provider_job.cpp) started the watchdog thread and then move-assigned it into state_.watchdog without holding watchdog_mu.

An on_expire callback that re-arms runs on the watchdog thread and reads that handle in the self-re-arm guard (state_.watchdog.joinable() && get_id() == this_thread). Nothing ordered that read after the body thread's store. The only ordering was the started semaphore, and it runs the other way: from the watchdog thread to the body thread.

If the body thread is preempted for longer than the timeout between thread creation and the store:

  1. The guard reads an empty handle and does not recognise the re-entry.
  2. stopWatchdog() skips the join.
  3. The watchdog thread assigns a new thread into state_.watchdog while the body thread is doing the same. The result is a torn std::thread, or std::terminate from assigning over a joinable one.

Fix

Store the handle while holding watchdog_mu. The watchdog thread locks watchdog_mu before anything else, so its later on_expire read happens after the handle is assigned. The started semaphore and the rest of the function are unchanged.

Evidence

  • ThreadSanitizer, Clang 22, Debug with PJ_ENABLE_TSAN=ON, both variants built in the same container with the same flags:
    • Unfixed: 20 of 20 runs of ProviderJobTest.RearmingTheWatchdogFromOnExpireIsANoOp report the race at provider_job.cpp:195 against the store at :205 (exit 66).
    • Fixed: 0 of 20 runs report it, and the full descriptor_import_provider_job_test passes 19/19.
  • Found by the PJ4 ThreadSanitizer lane (PlotJuggler/PJ4#654), which builds this SDK from source. The report is the only one left in that suite and stays there until PJ4's SDK pin moves.

Release

This is a consumer-visible bug fix, so a PATCH. CHANGELOG.md gets a 0.34.1 section with Host contract: unchanged (no floor impact). VERSION is left for the release commit.

JobControl::armWatchdog started the watchdog thread and then move-assigned it
into the job state without holding watchdog_mu. An on_expire callback that
re-arms from the watchdog thread reads that handle in the self-re-arm guard.
If the body thread was preempted for longer than the timeout between thread
creation and the store, the guard read an empty handle, and the re-arm then
joined or assigned over a live thread (std::terminate). The handle is now
stored under watchdog_mu, which the watchdog thread takes before anything
else.

ThreadSanitizer reported the race in
ProviderJobTest.RearmingTheWatchdogFromOnExpireIsANoOp on every run (20/20).
With the fix it reports nothing in 20 runs, and
descriptor_import_provider_job_test passes 19/19 under TSan.
…'s lock

The guard held across the handle store was named lock, like the unique_lock
the watchdog lambda declares, and GCC's -Wshadow rejected the build under
-Werror. It is now store_lock; behaviour is unchanged.
@GNERSIS

GNERSIS commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #200, which fixes the same JobControl::armWatchdog race with a thread-local re-entry guard and shipped in 0.34.1.

@GNERSIS GNERSIS closed this Sep 14, 2026
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