Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,16 @@ jobs:
working-directory: stress-tests/zig/lifecycle_churn
run: ./run.py --scenario reentrant --threads 4 --iterations 25 --tsan --timeout 300

# listener + cft each surfaced a real data race that now has a fix
# (see stress-tests/README.md "Findings"); a TSan lane pins them.
- name: lifecycle_churn/listener under ThreadSanitizer
working-directory: stress-tests/zig/lifecycle_churn
run: ./run.py --scenario listener --threads 4 --duration 20 --tsan --timeout 300

- name: lifecycle_churn/cft under ThreadSanitizer
working-directory: stress-tests/zig/lifecycle_churn
run: ./run.py --scenario cft --threads 6 --duration 20 --tsan --timeout 300

# Weekly full-vendor sweep trigger only: heavier, longer, + --large.
- name: Nightly heavy matrix
if: github.event_name == 'schedule'
Expand Down
82 changes: 82 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,88 @@ see [`docs/implementation_status.md`](docs/implementation_status.md); for planne
Dated entries (no release tags past `v0.2.1-zig.0.16.0`; `build.zig.zon` is
`0.2.1-zig.0.16.0-dev`).

## 2026-09-02

- **Pinned `zidl` v0.3.12-zig.0.16.0** (`build.zig.zon`) — the selective-parse family
(`deserialize_selected` / `KEY_FIELD_MASK` / `skipPrimitives`) that rewires
`get_key_value` and `get_field_from_cdr` off the key-only deserializer. The
`lifecycle_churn` `instance` scenario now **asserts** `get_key_value`'s returned
`subject_id` (non-leading `@key`) on both the writer and reader sides — it was
call-only-not-asserted while the fix was unreleased. Verified: 8 threads × 6 s, plus
ThreadSanitizer, clean; full stress suite green.
- **Fix — keyed writers now always send inline `PID_KEY_HASH`; a present all-zero hash is
honoured.** Two connected key-hash bugs the `instance` stress scenario exposed for a
zero-valued key (`subject_id == 0`):
1. `writer_sm.zig` suppressed the inline `PID_KEY_HASH` parameter whenever the computed
hash was all-zero bytes — indistinguishable from "keyless topic" but also true for a
legitimate zero key. A subscriber then had to reconstruct the per-instance hash from
the payload.
2. `resolveKeyHash` treated a *received* all-zero hash as "absent, recompute" rather
than as the value the writer sent.
Now: `TypeSupport` gains `has_key`; `pubCreateProtoWriter` marks the RTPS `StatefulWriter`
keyed (via new `setKeyed` / adapter passthrough); a keyed writer emits `PID_KEY_HASH` on
every DATA/DATA_FRAG including an all-zero key (`writer_sm.zig` `inlineKeyHash` helper).
`decodeKeyHash` returns `?[16]u8` and `resolveKeyHash` returns a *present* hash verbatim,
even all-zero. The C-ABI `zzdds_register_type_support{,_ctx}` infer `has_key` from a
non-NULL `compute_key_hash_fn` (per their documented contract); Zig-native callers set it
explicitly (stress harness updated). Regression: `test/dcps/type_support_test.zig` — a
keyed writer's zero-key sample routes by the wire hash and bypasses `key_hash_fn`, with a
control test showing the unchanged non-`has_key` fallback. Verified by re-breaking.
- **Known follow-up.** `TypeSupport.compute_key_hash`'s contract is "full CDR payload in,
hash out", but zidl's generated `computeKeyHashFromCdr` runs the key-only deserializer,
so a non-`has_key` fallback still misreads a non-leading `@key` from a non-zzdds peer that
omits the inline hash. Completion — route the fallback through
`deserialize_selected(KEY_FIELD_MASK)` on a full payload, K-flag-gated — needs a
`TypeSupport.compute_key_hash` signature change (`is_key_only: bool`) rippling to the
C ABI mirror and a further zidl release, so it is a follow-up beyond the v0.3.12 bump.
Tracked in `docs/roadmap.md` "Selective CDR parse — deferred follow-ups".

## 2026-08-30

- **Stress tests — five new `lifecycle_churn` churn scenarios.** On top of
`entities`/`reentrant`: `waitset` (threads attach/detach Read/QueryConditions on a
shared WaitSet while a waiter is in `wait()` and a waker flips a GuardCondition;
includes a deliberate delete-while-attached), `listener` (participant + publisher +
subscriber listeners installed; per-iteration `set_listener` swaps incl. `null` racing
entity teardown and event delivery), `cft` (a shared long-lived ContentFilteredTopic
hammered with `set_expression_parameters` while its reader is drained, alongside
unique-name CFT + reader lifecycle churn), `participants` (N threads each churning a
whole participant on one shared domain, listeners at every level, writer/reader
fan-in/fan-out — the participant level of the §2.2.4.1.5 fallback under teardown), and
`instance` (per-thread writer; `register_instance` / `write` / `dispose` /
`unregister_instance` / `get_key_value` / `lookup_instance` churn with a shared reader
fan-in). All gating in the `stress` CI job; `listener` and `cft` also run under
ThreadSanitizer. See `stress-tests/README.md`. The `instance` scenario surfaced two
pre-existing bugs: the concurrent-`write()` race (next entry, fixed here) and
`get_key_value` decoding the wrong key for a non-leading `@key` member — fixed in zidl
(a *selective-parse family*: `deserialize_selected(KEY_FIELD_MASK)` decodes just the
`@key` members and skips the rest, all four backends), landing here with the zidl
v0.3.12 `build.zig.zon` bump; the scenario's `get_key_value` value assertion is staged
for that PR (`zz-dev/zidl-v0.3.12-pin-bump-followups.md`).
- **Fix — concurrent `write()` on a single `DataWriter` was unsynchronised.**
`DataWriterImpl.writeRaw` updated `last_sn` and the `get_key_value` key registry (a
`HashMapUnmanaged`) with no lock, so two application threads calling `write()` /
`dispose()` / `unregister_instance()` on the same writer — spec-legal — raced on the
map's grow/insert and could abort on its `SafetyLock` (found by `instance` under
ThreadSanitizer). The RTPS layer under `proto_writer` was already internally locked; now
`last_sn` is a `std.atomic.Value` and the key registry is guarded by a dedicated
`key_registry_mu`. `docs/design/thread-model.md` documents the guarantee. Regression:
`test/dcps/writer_vtable_test.zig` — 6 threads × 40 keyed `write_raw` calls on one
writer plus a concurrent `getKeyValueRaw` poller; also runs in the `test-tsan` lane.
- **Fix — unsynchronised `listener_mask` (data race).** `listener_mask` was a plain
`u32` written unlocked by `set_listener` and read unlocked by the discovery/timer
dispatch path (`listener_mu` only ever covered the `ListenerBox` swap beside it).
Every *runtime* access in `src/dcps/{writer,reader,publisher,subscriber,participant,
topic}.zig` is now `@atomicLoad`/`@atomicStore` `.monotonic`; struct-literal
initialisers stay plain. Found by the `listener` scenario under TSan.
- **Fix — CFT `set_expression_parameters` use-after-free.** `ContentFilteredTopicImpl`
had no synchronisation on `expr_params`: `set_expression_parameters` frees the old
parameter strings + backing array and swaps in the new list while the receive thread's
`matchSample` is mid-`filter_mod.eval` holding those strings by reference (SEGV in
`parseFloat`). New `params_lock: Mutex` on the impl, held across `matchSample`'s eval
and around the swap in `set_expression_parameters` / the read in
`get_expression_parameters`. Found by the `cft` scenario (~40% repro at 12 threads).

## 2026-08-29

- **CI flake fix — unique DDS domain per test binary.** `zig build test` runs the ~29
Expand Down
4 changes: 2 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
.minimum_zig_version = "0.16.0",
.dependencies = .{
.zidl = .{
.url = "https://github.com/zz-iot/zidl/archive/refs/tags/v0.3.11-zig.0.16.0.tar.gz",
.hash = "zidl-0.3.11-zig.0.16.0-H6NwLiWeKwBt69SjWREwhsbM6fQEFZTdRIfdrzz9TKKy",
.url = "https://github.com/zz-iot/zidl/archive/refs/tags/v0.3.12-zig.0.16.0.tar.gz",
.hash = "zidl-0.3.12-zig.0.16.0-H6NwLvgwLADRpeAy5HYLSMMIV9u0PO-S4ek4IyXEUlCF",
},
},
.paths = .{
Expand Down
2 changes: 1 addition & 1 deletion docs/binding-release-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ zzdds client surfaces.
*(done — shipped in `zidl v0.3.11-zig.0.16.0`.)*
- Update `build.zig.zon` from the local `../zidl` path to the released zidl
package URL/hash after that tag exists.
*(done — `build.zig.zon` pins `zidl v0.3.11-zig.0.16.0` by URL + hash.)*
*(done — `build.zig.zon` pins `zidl v0.3.12-zig.0.16.0` by URL + hash.)*
- Keep `idl/dcps.idl` normative and put zzdds-specific configuration and
extension interfaces in `idl/zzdds.idl`.
- Keep `include/zzdds_c.h` as a small support ABI for generated wrappers; prefer
Expand Down
51 changes: 31 additions & 20 deletions docs/design/dcps-api-coverage-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ For each API found, classified as:
| Rejection/loss | `on_sample_rejected`/`on_sample_lost`, `get_sample_rejected_status`/`get_sample_lost_status` — neither listener nor polling form, anywhere |
| Historical data | `wait_for_historical_data` — confirmed zero across every harness |
| Timestamped/explicit instance ops | `register_instance` (explicit), `register_instance_w_timestamp`, `write_w_timestamp`, `dispose_w_timestamp`, `unregister_instance_w_timestamp` |
| Instance introspection | `get_key_value`, `lookup_instance` |
| Instance introspection | `lookup_instance`; `get_key_value` now exercised by the stress `instance` scenario, which found it returns the wrong key for non-leading-key types (zidl codegen, all backends — see below) |
| Loans | `return_loan_raw`, any loaned-read (`take_raw`/`read_raw` in loan mode) or write-loan (`loan_raw`/`publish_loan_raw`) path — no `zzdds-examples` port exercises these yet (internal test-suite coverage exists, see the loan-lifecycle entry below) |
| Entity admin, post-creation | `set_qos`/`get_qos` round-trip, `get_listener` read-back, `enable()`, `get_status_changes()`, `contains_entity()` |
| Discovery/ignore | `ignore_participant`/`ignore_topic`/`ignore_publication`/`ignore_subscription`, `get_discovered_participants`/`get_discovered_topics` + `_data` variants |
Expand Down Expand Up @@ -128,8 +128,10 @@ concurrency/lifecycle-under-load, `OpenDDS EntityLifecycleStress`-shaped).
needs 2 real processes to mean anything.
- **`set_expression_parameters` at runtime** (CFT dynamic reconfiguration) — does
changing parameters without recreating the CFT actually re-filter subsequent samples?
Real spec-mandated behavior, currently fully untested, and CFT has an established bug
history in this project (missing null-checks found in this same audit).
Real spec-mandated behavior; the *behavioural* question is still untested (the
stress-tier `cft` scenario now covers its *concurrency safety* and fixed a UAF there).
CFT has an established bug history in this project (missing null-checks found in this
same audit).
- **Coherent/ordered access grouping correctness** — build a real coherent set across
multiple writers, verify atomic delivery. High value given past CoherentSets
flakiness investigations.
Expand All @@ -156,23 +158,32 @@ concurrency/lifecycle-under-load, `OpenDDS EntityLifecycleStress`-shaped).
project has repeatedly found real bugs specifically in teardown-cascade edge cases.

### → Stress tests (new, in-repo)
- **Generalized reentrant-listener/entity-lifecycle churn** — the existing
`participant_vtable_test` "reentrant delete_participant from a timer-driven listener"
unit test is a single hand-built scenario; a stress test hammers this pattern with many
concurrent writers/readers/waitsets/listeners simultaneously creating/deleting/firing.
- **WaitSet/Condition churn under load** — many threads attaching/detaching conditions to
a shared WaitSet while `wait()` is blocked elsewhere, concurrent GuardCondition
set/reset — directly exercises the `CachedCAbiHandle`/`EntityQuiesce` machinery under
real pressure, not just narrow unit tests.
- **Listener-fallback chain under load** — concurrent reader/subscriber/participant
deletion racing concurrent `set_listener()` replacement and event delivery (this
project's most recent feature, see `docs/decisions.md` "Listener hierarchy fallback").
- **Many-writer/many-reader fan-in/fan-out discovery** — SPDP/SEDP under N participants
joining/leaving concurrently, matching OpenDDS Bench's discovery/fan-in/fan-out
scenario shapes at a small scale (targeted tests, not a full framework, to start).
- **Rapid DataWriter/DataReader create/delete during active SEDP matching** — plausible
source of use-after-free/leak bugs; matches OpenDDS `EntityLifecycleStress` directly
in spirit.
Landed in `stress-tests/` (`lifecycle_churn` scenarios + `entity_lifecycle_stress`):
- ~~**Generalized reentrant-listener/entity-lifecycle churn**~~ — `--scenario reentrant`.
- ~~**WaitSet/Condition churn under load**~~ — `--scenario waitset` (threads
attach/detach ReadConditions/QueryConditions on a shared WaitSet while a waiter is in
`wait()` and a waker flips a GuardCondition; includes delete-while-attached).
- ~~**Listener-fallback chain under load**~~ — `--scenario listener` (participant +
publisher + subscriber listeners; per-iteration `set_listener` swaps incl. `null`
racing entity teardown and event delivery). Found the unsynchronised `listener_mask`
race, now fixed + TSan-gated.
- ~~**Rapid DataWriter/DataReader create/delete during active SEDP matching**~~ —
`--scenario entities`. Found the discovery-driven listener-dispatch UAF, now fixed.
- **Runtime `set_expression_parameters` reconfiguration** — `--scenario cft`. Found a
UAF between the reconfigure and receive-thread filter eval, now fixed + TSan-gated.
- ~~**Many-writer/many-reader fan-in/fan-out discovery**~~ + ~~**participant-churning
fallback**~~ — `--scenario participants` (N threads each churning a whole participant on
one shared domain, listeners at every level, W/R mix for fan-in/fan-out). Clean.
- ~~**`instance` churn**~~ — `--scenario instance`. Clean for the instance-map /
reader-tracking / register-dispose-unregister paths, but surfaced two pre-existing bugs
it deliberately doesn't gate on: `get_key_value` parses the stored *full* sample with the
*key-only* deserializer in all four zidl backends (wrong key for any type whose key
member isn't first — see `stress-tests/README.md`), and concurrent `write()` on one
`DataWriter` is unsynchronised (`writeRaw` takes no lock). Each needs its own PR.

Still open:
- A scenario that churns the reader-side WaitSet/condition graph *and* the participant at
once (the closest current pair is `waitset` + `participants` run separately).

### Not prioritized / low value
- Condition introspection getters (`get_query_expression`, `get_sample_state_mask`, etc.)
Expand Down
7 changes: 7 additions & 0 deletions docs/design/thread-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ would invert locks held by the receive path.

`WaitSet.wait()` blocks only the calling thread.

Concurrent `DataWriter.write()` (and the `dispose`/`unregister_instance`/`write_raw`
family) from multiple application threads on a *single* writer is safe: the RTPS
`StatefulWriter`/`StatelessWriter` layer is internally locked, and `DataWriterImpl`'s own
per-write state (`last_sn`, the `get_key_value` key registry) is guarded
(`src/dcps/writer.zig` — atomic `last_sn`, `key_registry_mu`). Ordering between concurrent
writes is not defined — they interleave — but no write is lost or corrupted.

## Single-Threaded Direction

An embedded/single-threaded API such as `DomainParticipant.drive(timeout)` is not
Expand Down
Loading
Loading