12 KiB
Runtime and Concurrency Specification
This specification defines the concurrency baseline for Trail Mate targets. It exists because ESP32, nRF52, Linux, and tests have different runtime mechanics but must preserve the same ownership model.
The detailed design patterns, event simulation requirements, and burn-down
guidance for UI/storage responsiveness are defined in
UI_STORAGE_EVENT_RUNTIME_DESIGN_SPEC.md.
Active-path migrations that touch UI/storage/event responsiveness must satisfy that document's UML coverage gate before implementation begins.
Meshtastic Android App BLE connection is the concrete phone-BLE protocol case for
the ble_write rule below. Its full connection sequence, GATT drain semantics,
config snapshot state machine, response-drain-before-save rule, and forbidden
bypasses are specified in MESHTASTIC_ANDROID_BLE_CONNECTION_SPEC.md.
Sources of Concurrency
Trail Mate targets may receive work from:
Radio IRQ
Radio RX task or poll loop
Radio TX completion
GPS UART RX
GPS parser task
BLE stack callback
BLE notify queue
UI event loop
LVGL tick/input
GTK main loop
ASCII terminal input loop
HostLink USB/serial RX
Storage write
Config update
Power/sleep/wake
Timer/retry/ACK timeout
Mandatory Rules
ISR code may only defer.
BLE callbacks must not directly mutate app services.
Radio IRQ handlers must not run protocol or business logic.
GPS tasks must not directly update UI.
UI objects may only be updated on the UI owner thread/task.
Storage backends must declare their concurrency model.
Mutable app-service state must have a single owner context.
The UI owner context must not wait for blocking storage, device I/O, filesystem, decode, or persistence work. UI paths may submit commands, consume ready events, or attempt explicitly non-blocking work that can be abandoned within the frame budget.
Cross-thread and cross-task interaction must use one of:
event queue
command queue
immutable snapshot
declared mutex-protected store
Canonical Event Paths
radio_rx:
path: "radio_irq -> radio_task -> mesh_event_queue -> app_task"
rule: "IRQ defers; radio_task frames bytes; protocol/app processing runs outside IRQ."
gps_rx:
path: "uart_irq -> gps_task -> location_event_queue -> app_task"
rule: "GPS task may parse/normalize but may not directly mutate UI."
ble_write:
path: "ble_callback -> phone_command_queue -> app_task"
rule: "BLE stack callback owns transport timing only."
ui_input:
path: "ui_thread -> presentation_action -> app_command_queue"
rule: "Renderer translates input into UI actions; app owner executes business mutations."
Forbidden Paths
ble_callback -> ChatService
radio_irq -> MeshSession
gps_task -> lvgl
gtk_worker -> GtkWidget
ui_thread -> blocking storage write
ui_thread -> blocking device I/O wait
ui_thread -> filesystem open/read/write/list
ui_thread -> image decode from storage
ui_thread -> track file create/flush/list
ui_thread -> node/contact store synchronous save
ui_renderer -> direct radio access
ui_renderer -> direct GPS driver access
platform_driver -> direct message policy
UI Thread Only
LVGL:
lv_obj_* may only be called from the LVGL owner task/thread.
Other contexts post UI commands or publish snapshots.
GTK:
GtkWidget mutation may only happen on the GTK main loop.
Workers use main-context invocation, channels, or idle callbacks.
ASCII/TUI:
Terminal output has one renderer owner.
Input and refresh paths must not concurrently write stdout.
Headless:
No renderer owner exists; state is exposed through logs, API, or snapshots.
ISR Policy
ISR code may:
clear interrupt status
record minimal flags
post lightweight ISR-safe events
ISR code must not:
malloc/free
write storage
notify BLE
update UI
encode/decode protobuf
send direct messages
parse GPS sentences
perform crypto
Storage Concurrency
Every storage backend must declare:
single writer or multiple writers
reader model
transaction support
async write support
erase/write blocking behavior
required owner context or mutex
UI-owner behavior
queue/backpressure behavior
diagnostic fields for slow waits
Examples:
ESP32 NVS: blocking, write-limited, usually mutex protected.
nRF52 flash: erase/write expensive, often async, callback-sensitive.
SQLite: transactional with file locks; must avoid UI-thread blocking.
Mutable State Ownership
Every mutable service must have one owner:
ChatService -> app service context
MeshSession -> mesh/app context declared by target
GpsService -> gps/app context declared by target
ConfigService -> app service context
DeviceStatusService -> app service context
UI State -> UI context or presentation owner
Other contexts may send commands, publish events, or consume snapshots.
Slow Work Ownership
Slow work must have an explicit owner. Page widgets, LVGL timers, GTK callbacks, and input handlers are not valid owners for durable storage, filesystem walking, tile decode, device I/O waits, protocol retries, or persistence flushes.
Valid slow-work owners include:
command worker
protocol runtime worker
map tile worker
track storage worker
StorageMaintenanceRuntime
ConfigPersistenceRuntime
domain store owner
declared platform service task
Slow-work owners communicate completion through events or immutable snapshots. They must not mutate concrete UI objects directly.
Simulation Requirement
Any runtime that introduces asynchronous command/event behavior must be testable with deterministic simulated events. Tests must be able to script:
command enqueue
event publish
worker completion
timeout
cancellation
storage delay
storage failure
bus arbitration delay
UI event drain
The simulator must assert that UI owner code does not execute blocking storage/device-I/O/filesystem calls and that background code does not execute concrete renderer calls.
Storage Maintenance Owner
SD-backed maintenance is one active object, not one task per operation. The
owner task is created once, consumes a bounded command queue, and remains
blocked after maintenance reaches Done. A FreeRTOS task handle is an
implementation detail and must not be used as the business state.
The maintenance owner publishes an immutable StorageRuntimeSnapshot with a
monotonic StorageOperationGeneration. Every operation completion carries the
operation and generation that produced it. A completion for an older
generation is ignored and cannot move the current state backward.
The maintenance state machine is limited to:
Dormant
-> WaitingStartupGate
-> Hydrating(generation)
-> Ready
-> WaitingIdle
-> Persisting(generation)
-> Compacting(generation)
-> Done
Hydrating / Persisting / Compacting -> Backoff -> the same operation
Normal hydration, persistence, and compaction completion returns to `Ready`.
`Done` is reserved for an explicit stop or an exhausted retry policy. The
owner receives a latest-state maintenance demand rather than an unbounded
timer: pending immutable deltas request `Persist`, while reset intents or
compaction thresholds request `Compact`. When both are pending, active
foreground work is allowed to drain bounded persistence steps first; after
the idle gate is stable, compaction takes precedence because its snapshot
already includes the newest in-memory projections.
Foreground contexts may enqueue ticks and consume snapshots or one-shot
hydration-ready events. They may not mutate maintenance state or call the
maintenance backend directly. The owner sees only ISemanticStorageAdapter;
SPI tokens, chip-select pins, filesystem sessions, mutexes, and task handles
must remain behind that adapter.
Maintenance readiness must not stop the foreground UI, input, or event loop. Foreground presentation reads observe a semantic not-ready result while the authoritative projection is being installed, and retain a retryable view state instead of treating an empty projection as valid data. Device sessions, bus ownership, and lock details remain inside the storage adapters.
The adapter contract is intentionally incremental:
begin(operation, generation)
step(operation, generation, budget)
cancelAtStepBoundary(operation, generation)
StorageOperationBudget is expressed in logical work items. A backend must
perform filesystem/device work outside its logical state lock, then take a
short bounded lock only to apply the decoded result or commit a generation.
For SD-backed repositories, a separate persistence lease serializes the
multi-step physical writer; foreground mutations enqueue immutable projections
or reset intents while that lease is held.
Retry semantics are part of the adapter contract, not an implementation convention:
begin(operation, generation)with a new generation initializes a new operation cursor.begin(operation, generation)with the same non-terminal operation and generation resumes the existing cursor afterRetryLater,StateBusy,DeviceUnavailable, or another retryable result.- The repository's logical maintenance-ownership lease remains held across
those retries. The physical filesystem/device transaction lease may be
released at the end of a bounded step, so the next
beginresumes under the existing ownership without competing with foreground persistence. - Exhausting the owner's retry policy is a terminal cancellation boundary.
Before publishing
Done, the owner must callcancelAtStepBoundary(operation, generation)so the adapter discards the cursor and releases every retained logical maintenance lease. - A different generation, an explicit cancellation, or a terminal backend phase is the only valid reason to discard that cursor.
The three concurrency boundaries remain distinct:
logical repository state lock
physical filesystem/device session
shared SPI transaction
Hydration and compaction must acquire the logical state lock with a bounded
wait, release it between protocol/journal units where the backend permits, and
never use portMAX_DELAY across SD I/O. Shared-SPI boards must wait for a real
display transaction completion before hydration. SDMMC or independent-SPI
boards must use an already-satisfied gate and must not inherit a display delay.
The board runtime exposes storage topology capabilities rather than making the storage runtime maintain a board-name macro list. The capability distinguishes shared-display SPI, dedicated SPI, and SDMMC.
Configuration Persistence Owner
Configuration persistence is a separate owner from SD maintenance. It owns configuration dirty state, debounce, immutable payloads, generation tracking, critical flush requests, and retry decisions. It does not own SD hydration, compaction, map tile reads, or repository maintenance.
The configuration owner exposes only semantic work:
submit(snapshot, change_set, urgency)
takeDue(now_ms, work)
complete(generation, result)
The platform execution shell owns only task scheduling and adapter invocation.
It must not reconstruct the pending/in-flight state machine in a second set of
flags. AppContext facade methods are compatibility entry points; they submit
an edit or a persistence intent to the configuration owner.
The owner keeps three snapshots:
baseline = last successful persisted snapshot
pending = newest requested snapshot not yet started
active = immutable snapshot currently being persisted
The following invariants are mandatory:
activeis never mutated while an adapter call is in flight.- A completion with a stale generation cannot change owner state.
- A successful completion advances
baselinebefore reconcilingpending. - If the latest configuration reverted while an older write was in flight, the latest value remains pending until it is persisted or explicitly cancelled.
- A failed completion invalidates
baselineand retries conservatively. - Configuration snapshots and protocol payloads must not be automatic locals on ESP task stacks.
Storage maintenance and configuration persistence may share result kinds, generation semantics, retry policy conventions, and semantic adapter patterns, but they must not share one giant state machine or business snapshot. Their owners, payloads, and lifecycle remain independent.