-
-
Notifications
You must be signed in to change notification settings - Fork 17
Fix TC crash: unsynchronized MyTCServer::clients + null derefs in DDOP parsing #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
8baa130
Fix TC crash: unsynchronized MyTCServer::clients + null derefs in DDO…
gunicsba e7d815a
Apply clang-format/cmake-format to the crash-handler files
gunicsba 69c9c68
Update concurrency documentation for callbacks
gunicsba f6c7999
Fix async-signal-safety and portability issues in crash_handler.cpp
gunicsba 79c2386
Refactor crash handler for improved signal management
gunicsba be646ac
Fix clang-format style for crash_handler
Copilot 026a87d
Populate activation error/fault details in activate_object_pool and u…
gunicsba 2d8fbbf
Use UnknownObjectReference error code when Device object is missing
gunicsba File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| # AOG-TaskController — Concurrency Model | ||
|
|
||
| This document exists because the threading model here is easy to get wrong by analogy with the | ||
| main loop, and a wrong assumption here previously produced a crash with **zero trace** — no | ||
| exception, no log line, no console output. Read this before adding a new callback (a | ||
| `TaskControllerServer` override, an event-dispatcher listener, a raw | ||
| `add_global_parameter_group_number_callback`) or before touching state that such a callback reads | ||
| or writes. | ||
|
|
||
| ## The two threads | ||
|
|
||
| `isobus::CANHardwareInterface::start()` (called once, in `Application::setup_can_hardware()`) | ||
| spawns its own background thread by default — this is standard AgIsoStack behavior, not something | ||
| this repo opted into. That thread calls `CANNetworkManager::CANNetwork.update()` internally, which | ||
| 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. | ||
|
|
||
| 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: | ||
|
|
||
| - **Runs on the CAN stack background thread by default** (CANHardwareInterface's updateThread): `TaskControllerServer` | ||
| (`MyTCServer`). Treat every `TaskControllerServer` override (`activate_object_pool`, | ||
| `on_value_command`, ...) as background-thread code unless you have traced the library | ||
| implementation and proven it is deferred to `TaskControllerServer::update()`. | ||
| - **Runs directly on the background thread, synchronously, as the frame is processed** — not | ||
| 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 | ||
| `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. | ||
|
|
||
| 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 | ||
| registration point itself defers to an `update()` call, or invokes the listener/dispatcher inline. | ||
|
|
||
| ## What's protected today | ||
|
|
||
| - **`MyTCServer::clients` / `uploadedPools`** (`task_controller.hpp`/`.cpp`) — guarded by | ||
| `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. | ||
|
|
||
| ## What's *not* 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. | ||
|
|
||
| ## 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 | ||
| 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.** |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| /** | ||
| * @file crash_handler.hpp | ||
| * @brief Last-resort crash diagnostics: writes a crash log (and, on Windows, a | ||
| * minidump) to the config "logs" directory before the process dies. | ||
| * | ||
| * This exists because AOG-TaskController normally runs with no visible console | ||
| * (launched by AgIO/AOG) and without --log2file, so a hard crash (access | ||
| * violation, uncaught exception, std::terminate) otherwise leaves zero trace. | ||
| * install_crash_handlers() should be called once, as early as possible in | ||
| * main/WinMain. | ||
| */ | ||
| #pragma once | ||
|
|
||
| #include <string> | ||
|
|
||
| /// @brief Install process-wide crash handlers (SEH filter + minidump on | ||
| /// Windows, signal handlers on POSIX; std::terminate handler on both). | ||
| void install_crash_handlers(); | ||
|
|
||
| /// @brief Append a line to the same crash log file the handlers above write to | ||
| /// (config dir's logs/crash_<timestamp>.log), regardless of whether --log2file | ||
| /// was passed. For fatal-but-caught conditions (e.g. an exception caught at the | ||
| /// top of main()) that should still leave a trace on disk. | ||
| void log_crash(const std::string &reason); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| #include "crash_handler.hpp" | ||
|
|
||
| #include "logging_utils.hpp" | ||
| #include "settings.hpp" | ||
|
|
||
| #include <cstdio> | ||
| #include <cstdlib> | ||
| #include <ctime> | ||
| #include <exception> | ||
| #include <fstream> | ||
| #include <iostream> | ||
| #include <sstream> | ||
| #include <string> | ||
|
|
||
| namespace | ||
| { | ||
| std::string crash_file_path(const std::string &extension) | ||
| { | ||
| std::time_t now = std::time(nullptr); | ||
| std::tm localTime; | ||
| #if defined(_WIN32) | ||
| localtime_s(&localTime, &now); | ||
| #else | ||
| localtime_r(&now, &localTime); | ||
| #endif | ||
| char stamp[32]; | ||
| std::snprintf(stamp, sizeof(stamp), "%04d-%02d-%02d_%02d-%02d-%02d", localTime.tm_year + 1900, localTime.tm_mon + 1, localTime.tm_mday, localTime.tm_hour, localTime.tm_min, localTime.tm_sec); | ||
| // Settings::get_filename_path() throws if it can't create the directory; a crash | ||
| // handler must never throw, so fall back to the current directory on failure. | ||
| try | ||
| { | ||
| return Settings::get_filename_path(std::string("logs/crash_") + stamp + extension); | ||
| } | ||
| catch (...) | ||
| { | ||
| return std::string("crash_") + stamp + extension; | ||
| } | ||
| } | ||
|
|
||
| void log_terminate_reason() | ||
| { | ||
| std::string reason = "std::terminate called"; | ||
| if (auto currentException = std::current_exception()) | ||
| { | ||
| try | ||
| { | ||
| std::rethrow_exception(currentException); | ||
| } | ||
| catch (const std::exception &e) | ||
| { | ||
| reason += std::string(" due to unhandled exception: ") + e.what(); | ||
| } | ||
| catch (...) | ||
| { | ||
| reason += " due to an unhandled exception of unknown type"; | ||
| } | ||
| } | ||
| else | ||
| { | ||
| reason += " with no active exception (direct std::terminate()/std::abort(), a noexcept " | ||
| "violation, or a failure during stack unwinding)"; | ||
| } | ||
| log_crash(reason); | ||
| } | ||
| } // namespace | ||
|
|
||
| void log_crash(const std::string &reason) | ||
| { | ||
| std::ofstream out(crash_file_path(".log"), std::ios::app); | ||
| std::ostream &sink = out.is_open() ? static_cast<std::ostream &>(out) : std::cout; | ||
| sink << "[" << get_timestamp() << "] [Crash] " << reason << std::endl; | ||
| } | ||
|
|
||
| #if defined(_WIN32) | ||
|
|
||
| #include <windows.h> | ||
|
|
||
| #include <dbghelp.h> | ||
|
|
||
| #pragma comment(lib, "dbghelp.lib") | ||
|
|
||
| namespace | ||
| { | ||
| LONG WINAPI unhandled_exception_filter(EXCEPTION_POINTERS *exceptionPointers) | ||
| { | ||
| const std::string dumpPath = crash_file_path(".dmp"); | ||
|
|
||
| { | ||
| std::ostringstream reason; | ||
| reason << "Unhandled SEH exception 0x" << std::hex << exceptionPointers->ExceptionRecord->ExceptionCode | ||
| << std::dec << " at address " << exceptionPointers->ExceptionRecord->ExceptionAddress | ||
| << ". Writing minidump to " << dumpPath; | ||
| log_crash(reason.str()); | ||
| } | ||
|
|
||
| HANDLE dumpFile = CreateFileA(dumpPath.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); | ||
| if (dumpFile != INVALID_HANDLE_VALUE) | ||
| { | ||
| MINIDUMP_EXCEPTION_INFORMATION mdInfo{}; | ||
| mdInfo.ThreadId = GetCurrentThreadId(); | ||
| mdInfo.ExceptionPointers = exceptionPointers; | ||
| mdInfo.ClientPointers = FALSE; | ||
|
|
||
| MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dumpFile, MiniDumpWithIndirectlyReferencedMemory, &mdInfo, nullptr, nullptr); | ||
| CloseHandle(dumpFile); | ||
| } | ||
|
|
||
| // Let Windows terminate the process normally after we've captured diagnostics. | ||
| return EXCEPTION_EXECUTE_HANDLER; | ||
| } | ||
| } // namespace | ||
|
|
||
| void install_crash_handlers() | ||
| { | ||
| SetUnhandledExceptionFilter(unhandled_exception_filter); | ||
| std::set_terminate(log_terminate_reason); | ||
| } | ||
|
|
||
| #else // POSIX | ||
|
|
||
| #include <unistd.h> | ||
| #include <csignal> | ||
|
|
||
| namespace | ||
| { | ||
| void fatal_signal_handler(int signalNumber) | ||
| { | ||
| const char *name = "unknown signal"; | ||
| switch (signalNumber) | ||
| { | ||
| case SIGSEGV: | ||
| name = "SIGSEGV (segmentation fault)"; | ||
| break; | ||
| case SIGABRT: | ||
| name = "SIGABRT (abort)"; | ||
| break; | ||
| case SIGFPE: | ||
| name = "SIGFPE (arithmetic error)"; | ||
| break; | ||
| case SIGILL: | ||
| name = "SIGILL (illegal instruction)"; | ||
| break; | ||
| case SIGBUS: | ||
| name = "SIGBUS (bus error)"; | ||
| break; | ||
| default: | ||
| break; | ||
| } | ||
|
|
||
| // Signal-handler-safe I/O only: no iostreams, no dynamic allocation, no | ||
| // get_timestamp(), and no strlen() — it's not on POSIX's async-signal-safe | ||
| // function list, so a hand-rolled length count is used instead. | ||
| const int fd = 2; // stderr | ||
| auto writeRaw = [fd](const char *text) { | ||
| std::size_t len = 0; | ||
| while (text[len] != '\0') | ||
| { | ||
| ++len; | ||
| } | ||
| (void)write(fd, text, len); | ||
| }; | ||
| writeRaw("[Crash] Fatal signal: "); | ||
| writeRaw(name); | ||
| writeRaw("\n"); | ||
|
|
||
| // Re-trigger the signal with default disposition so the process terminates normally | ||
| // (and may produce a core dump), without calling non-async-signal-safe functions. | ||
| ::kill(::getpid(), signalNumber); | ||
| ::_exit(128 + signalNumber); | ||
| } | ||
| } // namespace | ||
|
|
||
| void install_crash_handlers() | ||
| { | ||
| struct sigaction sa | ||
| { | ||
| }; | ||
| sa.sa_handler = fatal_signal_handler; | ||
| sigemptyset(&sa.sa_mask); | ||
| sa.sa_flags = SA_RESETHAND; | ||
| const int fatalSignals[] = { SIGSEGV, SIGABRT, SIGFPE, SIGILL, SIGBUS }; | ||
| for (int sig : fatalSignals) | ||
| { | ||
| (void)sigaction(sig, &sa, nullptr); | ||
| } | ||
| std::set_terminate(log_terminate_reason); | ||
| } | ||
|
|
||
| #endif | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.