Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/validate-iop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
-Wno-dev

- name: Build
run: cmake --build build --config Release --target iop_validator
run: cmake --build build --config Release --target iop_validator test_tractor_facilities

- name: Validate object pool
run: ctest --test-dir build --output-on-failure
17 changes: 16 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ FetchContent_MakeAvailable(Boost)
FetchContent_Declare(
isobus
GIT_REPOSITORY https://github.com/gunicsba/AgIsoStack-plus-plus.git
GIT_TAG d4d36dd6f15af6c6f6218317572025a1ffec6883
GIT_TAG e79699e9f58b332d2c1146ebeb5e25c8908c0e35
DOWNLOAD_EXTRACT_TIMESTAMP TRUE)
FetchContent_MakeAvailable(isobus)

Expand Down Expand Up @@ -185,6 +185,21 @@ if(AOG_TC_VALIDATE_IOP)
add_test(NAME malformed_pool_exit_status COMMAND iop_validator
"${AOG_TC_MALFORMED_IOP}")
set_tests_properties(malformed_pool_exit_status PROPERTIES WILL_FAIL TRUE)

# Tractor Facilities (PGN 65033) encode/decode unit tests
add_executable(
test_tractor_facilities
${CMAKE_CURRENT_LIST_DIR}/tools/test_tractor_facilities.cpp
${CMAKE_CURRENT_LIST_DIR}/src/tractor_facilities.cpp)
target_compile_features(test_tractor_facilities PRIVATE cxx_std_20)
set_target_properties(test_tractor_facilities PROPERTIES CXX_EXTENSIONS OFF)
target_include_directories(test_tractor_facilities
PRIVATE ${CMAKE_CURRENT_LIST_DIR}/include)
target_link_libraries(
test_tractor_facilities PRIVATE isobus::Isobus isobus::HardwareIntegration
isobus::Utility)
add_test(NAME tractor_facilities_encode_decode
COMMAND test_tractor_facilities)
endif()

if(WIN32)
Expand Down
51 changes: 32 additions & 19 deletions docs/CONCURRENCY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,18 @@ this repo opted into. That thread calls `CANNetworkManager::CANNetwork.update()`
is what actually parses incoming CAN frames, handles address claims, and dispatches every PGN to
whatever is registered to receive it.

Meanwhile, `Application::update()` — the loop driven from `main.cpp`, doing UDP handling and
periodic sends — runs on the **main thread**. It never calls `CANHardwareInterface::update()`
itself; it relies entirely on that background thread.
Meanwhile, `Application::update()` — the loop driven from `main.cpp`, doing UDP handling, periodic
sends, VT status polling — runs on the **main thread**. It never calls
`CANHardwareInterface::update()` itself; it relies entirely on that background thread.

So: **two threads exist for the lifetime of the process**, and the question for any new code is
which one it runs on.

## Which callbacks run on which thread — checked, not assumed

Don't guess this from a class's "manual update mode" comment (that pattern is unrelated to this
question and led to a wrong conclusion once already). Check how the *specific* callback is
registered:
question and led to a wrong conclusion once already — see git history around 2026-09-04). Check
how the *specific* callback is registered:

- **Runs on the CAN stack background thread by default** (CANHardwareInterface's updateThread): `TaskControllerServer`
(`MyTCServer`). Treat every `TaskControllerServer` override (`activate_object_pool`,
Expand All @@ -36,10 +36,12 @@ registered:
deferred to any `update()` call: `VirtualTerminalClient` (confirmed: `process_rx_message` is
registered via `add_global_parameter_group_number_callback` and invokes
`softKeyEventDispatcher`/`buttonEventDispatcher`/`changeNumericValueEventDispatcher`/etc.
synchronously). Any raw
synchronously) and `TimeDateInterface` (same pattern — `process_rx_message` invokes
`timeAndDateEventDispatcher.invoke(...)` directly). Any raw
`add_global_parameter_group_number_callback`/`add_any_control_function_parameter_group_number_callback`
registered directly in `app.cpp` is in this category too, unless proven otherwise the same way:
read the registration call, not the surrounding comments.
registered directly in `app.cpp`/`tractor_facilities.cpp` (PGN-request handlers, the FEE6
duplicate-provider listener, diagnostic loggers) is in this category too, unless proven
otherwise the same way: read the registration call, not the surrounding comments.

If you're not sure which category a new callback falls into, trace it the way this file's history
did: find where it's registered with `CANNetworkManager`/`ControlFunction` and check whether that
Expand All @@ -51,29 +53,40 @@ registration point itself defers to an `update()` call, or invokes the listener/
`MyTCServer::clientsMutex` (a `std::recursive_mutex`, since several methods call each other:
e.g. `update_section_states()` → `send_section_setpoint_states()`). Locked at the top of every
`TaskControllerServer` override and every method `Application` calls into. `get_clients()`
returns a **copy**, not a reference — call sites hold the result across multiple operations or
take a second `get_clients()` call, either of which would leave a dangling reference to a
temporary if it returned by reference. If you add a new method that reads or writes `clients`,
take `clientsMutex` at its top, even though (per the note above) the `TaskControllerServer`
overrides may turn out to already be main-thread-only — the lock is cheap insurance and keeps
every entry point consistent regardless of how the library's internals might change.
returns a **copy**, not a reference — several call sites hold the result across multiple
operations or take a second `get_clients()` call, either of which would leave a dangling
reference to a temporary if it returned by reference. If you add a new method that reads or
writes `clients`, take `clientsMutex` at its top, even though (per the note above) the
`TaskControllerServer` overrides may turn out to already be main-thread-only — the lock is cheap
insurance and keeps every entry point consistent regardless of how the library's internals
might change.
- **`Application::lastExternalFee6Ms` / `fee6Broadcasting`** (`app.hpp`/`.cpp`) — guarded by
`Application::fee6Mutex`. Written from both the `TimeDateInterface` listener (background thread)
and `Application::update()`'s FEE6 broadcast block (main thread) — confirmed via the
registration-tracing method above.
- **`TractorFacilities::timeDateActive`** (`tractor_facilities.hpp`/`.cpp`) — `std::atomic<bool>`
rather than a mutex, since it's a single flag: written by `set_time_date_active()` (called from
both threads, same as above) and read by `build_payload()` (called both on power-up from the main
thread, and from the PGN 65033 request handler on the background thread).

## What's *not* protected yet — known gap
## What's *not* fully protected yet — known gap

Calls into `vtClient` (`isobus::VirtualTerminalClient`) happen from both the main thread
(`Application::update_vt_client()` and everything it calls: `send_vt_string_if_changed`,
`vtUpdateHelper`, etc.) **and** from the VT event-dispatcher lambdas registered in
`setup_vt_client()` (soft-key, button, numeric-value-change), which — per the confirmed
registration pattern above — run on the background thread. Whether `VirtualTerminalClient`'s own
internal state is safe under that concurrent access is not verified; no external mutex wraps
`vtClient` usage today. If a crash dump ever points into `isobus::VirtualTerminalClient`, or a
crash correlates with VT/AUX-N activity rather than DDOP upload, this is the first place to look.
`vtClient` usage today. Given the original crash was reported as "the moment a new device (VT
capable) appeared on the bus," this is a credible remaining suspect and should be the first place
to look if a crash dump points into `isobus::VirtualTerminalClient` or if a new crash correlates
with VT/AUX-N activity rather than DDOP upload.

## Also worth knowing: `get_object_by_index()` can return null

Unrelated to threading, but found while investigating a real crash report ("usually happens right
after DDOP loading"): `isobus::DeviceDescriptorObjectPool::get_object_by_index()` can return
`nullptr` for an index within `[0, size())` — most likely a symptom of a partially- or
after DDOP loading," predates the VT client entirely): `isobus::DeviceDescriptorObjectPool::get_object_by_index()`
can return `nullptr` for an index within `[0, size())` — most likely a symptom of a partially- or
incorrectly-parsed pool (e.g. a multi-chunk DDOP transfer). Two call sites in this file already
null-checked it; the rest didn't and were fixed alongside the mutex work above. **Any new call to
`get_object_by_index()` must null-check the result before dereferencing.**
Loading
Loading