diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b05edf9..3c8308f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: "0.16.0" @@ -92,7 +92,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: "0.16.0" @@ -235,7 +235,7 @@ jobs: uses: actions/checkout@v4 - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: "0.16.0" @@ -294,7 +294,7 @@ jobs: uses: actions/checkout@v4 - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: "0.16.0" @@ -517,6 +517,59 @@ jobs: --sub-marker "subscriber: done" --pub-marker "publisher: done" --timeout 60 \ --ld-library-path "$GITHUB_WORKSPACE/zig-out/lib" + # ── Stress tests ───────────────────────────────────────────────────────── + # + # stress-tests/ -- concurrency- and lifecycle-under-load tests, OpenDDS + # EntityLifecycleStress-shaped. Deliberately outside `zig build test` + # (wall-clock, real UDP, non-deterministic). Mirrors the `examples` job: + # build zzdds native + -Ddebug-allocator, then run_all.py --strict with + # CI-sized parameters. run_all.py's own xfail list keeps a currently-known + # zzdds bug (see stress-tests/README.md "Findings") from reddening the gate + # while still exercising it. The nightly `schedule` trigger runs a heavier + # matrix below. + stress: + name: stress + needs: test-linux + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - uses: actions/checkout@v4 + + - name: Install Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: "0.16.0" + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # setarch -R (disable ASLR) is applied per-process by _common.LiveProcess + # for the TSan lane; this lowers the shadow-map-incompatible entropy for + # anything that slips through (build-time compiler probes etc.). + - name: Reduce ASLR entropy (for the TSan churn lane) + run: sudo sysctl -w vm.mmap_rnd_bits=28 + + - name: Build zzdds (native, DebugAllocator) + run: zig build -Ddebug-allocator=true install + + - name: Run stress suite (strict) + run: stress-tests/run_all.py --strict + + - name: lifecycle_churn/reentrant under ThreadSanitizer + working-directory: stress-tests/zig/lifecycle_churn + run: ./run.py --scenario reentrant --threads 4 --iterations 25 --tsan --timeout 300 + + # Weekly full-vendor sweep trigger only: heavier, longer, + --large. + - name: Nightly heavy matrix + if: github.event_name == 'schedule' + working-directory: stress-tests/zig/entity_lifecycle_stress + run: | + ./run.py --publishers 12 --subscribers 12 --samples 750 --churn --timeout 300 + ./run.py --publishers 6 --subscribers 6 --large --churn --timeout 300 + # ── Coverage ────────────────────────────────────────────────────────────── # # Builds all test binaries to zig-out/tests/, runs each through kcov @@ -542,7 +595,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: "0.16.0" @@ -592,7 +645,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: "0.16.0" @@ -817,7 +870,7 @@ jobs: (cd dds-rtps/srcZig/zzdds && python3 point_zon_at_symlinks.py) - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: "0.16.0" @@ -890,7 +943,7 @@ jobs: (cd dds-rtps/srcZig/zzdds && python3 point_zon_at_symlinks.py) - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: "0.16.0" @@ -1160,7 +1213,7 @@ jobs: (cd dds-rtps/srcZig/zzdds && python3 point_zon_at_symlinks.py) - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: "0.16.0" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 061889d5..dece1337 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: echo "Tag will be: ${TAG}" - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: ${{ steps.ver.outputs.zig_version }} @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: ${{ needs.prepare.outputs.zig_version }} @@ -211,7 +211,7 @@ jobs: (cd dds-rtps/srcZig/zzdds && python3 point_zon_at_symlinks.py) - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: ${{ needs.prepare.outputs.zig_version }} @@ -278,7 +278,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Zig - uses: mlugg/setup-zig@v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: ${{ needs.prepare.outputs.zig_version }} diff --git a/.gitignore b/.gitignore index d90f2680..b84a9474 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ test/interop/interop-artifacts-*.tar.gz *~ .idea/ .vscode/ +__pycache__/ diff --git a/src/dcps/participant.zig b/src/dcps/participant.zig index 0f555080..027e49a0 100644 --- a/src/dcps/participant.zig +++ b/src/dcps/participant.zig @@ -536,6 +536,28 @@ const IncompatQosNotify = struct { const MatchedNotify = struct { ctx: *anyopaque, notify: *const fn (ctx: *anyopaque, remote_handle: DDS.InstanceHandle_t, added: bool) void, + /// `ctx` is a `*DataReaderImpl` when true, a `*DataWriterImpl` when false — + /// needed to reach the right `EntityQuiesce` from `onReaderDiscovered` / + /// `onWriterDiscovered`, which release `participant.mu` before firing + /// `notify` and so must pin the target entity themselves (a raw `ctx` is + /// otherwise free to dangle in that window — see `entity_quiesce.zig`). + is_reader: bool, + + /// Call while holding `participant.mu` (so `ctx` is provably still live); + /// hold the reference until after `notify` returns, then `quiesceRelease`. + /// False = the target entity is tearing down; skip the notify entirely. + fn quiesceAcquire(self: MatchedNotify) bool { + return if (self.is_reader) + reader_mod.DataReaderImpl.quiesceAcquireFn(self.ctx) + else + writer_mod.DataWriterImpl.quiesceAcquireFn(self.ctx); + } + fn quiesceRelease(self: MatchedNotify) void { + if (self.is_reader) + reader_mod.DataReaderImpl.quiesceReleaseFn(self.ctx) + else + writer_mod.DataWriterImpl.quiesceReleaseFn(self.ctx); + } }; /// Callback registered by DataWriterImpl / DataReaderImpl so that the participant @@ -2215,6 +2237,7 @@ pub const DomainParticipantImpl = struct { proto: proto.ProtocolReader, info: proto.MatchedWriterInfo, notify: ?MatchedNotify, + notify_quiesced: bool = false, }; fn onWriterDiscovered(ctx: *anyopaque, data: *const disc.WriterData) void { @@ -2270,7 +2293,11 @@ pub const DomainParticipantImpl = struct { // races it; see protocol/interface.zig's quiesce_acquire doc. if (!ar.proto.quiesceAcquire()) continue; const info = buildMatchedWriterInfo(data.guid, data.qos, data.unicast_locators, data.multicast_locators); - jobs.append(self.alloc, .{ .proto = ar.proto, .info = info, .notify = ar.matched_notify }) catch ar.proto.quiesceRelease(); + const nq = if (ar.matched_notify) |cb| cb.quiesceAcquire() else false; + jobs.append(self.alloc, .{ .proto = ar.proto, .info = info, .notify = ar.matched_notify, .notify_quiesced = nq }) catch { + ar.proto.quiesceRelease(); + if (nq) ar.matched_notify.?.quiesceRelease(); + }; } upsertDiscoveredWriter(self, data); if (self.builtin_sub) |bs| push_dr = bs.pub_dr; @@ -2308,7 +2335,10 @@ pub const DomainParticipantImpl = struct { self.mu.unlock(); for (jobs.items) |job| { job.proto.addMatchedWriter(&job.info) catch {}; - if (job.notify) |cb| cb.notify(cb.ctx, writer_mod.guidToHandle(data.guid), true); + if (job.notify) |cb| if (job.notify_quiesced) { + cb.notify(cb.ctx, writer_mod.guidToHandle(data.guid), true); + cb.quiesceRelease(); + }; job.proto.quiesceRelease(); } if (push_dr) |dr| pushBuiltinPublicationCdr(self.alloc, dr, data); @@ -2379,6 +2409,7 @@ pub const DomainParticipantImpl = struct { proto: proto.ProtocolWriter, info: proto.MatchedReaderInfo, notify: ?MatchedNotify, + notify_quiesced: bool = false, }; fn onReaderDiscovered(ctx: *anyopaque, data: *const disc.ReaderData) void { @@ -2427,7 +2458,11 @@ pub const DomainParticipantImpl = struct { // until after self.mu is released below. if (!aw.proto.quiesceAcquire()) continue; const info = self.buildMatchedReaderInfo(data.guid, data.qos, data.unicast_locators, data.multicast_locators); - jobs.append(self.alloc, .{ .proto = aw.proto, .info = info, .notify = aw.matched_notify }) catch aw.proto.quiesceRelease(); + const nq = if (aw.matched_notify) |cb| cb.quiesceAcquire() else false; + jobs.append(self.alloc, .{ .proto = aw.proto, .info = info, .notify = aw.matched_notify, .notify_quiesced = nq }) catch { + aw.proto.quiesceRelease(); + if (nq) aw.matched_notify.?.quiesceRelease(); + }; } upsertDiscoveredReader(self, data); if (self.builtin_sub) |bs| push_dr = bs.sub_dr; @@ -2465,7 +2500,10 @@ pub const DomainParticipantImpl = struct { self.mu.unlock(); for (jobs.items) |job| { job.proto.addMatchedReader(&job.info) catch {}; - if (job.notify) |cb| cb.notify(cb.ctx, writer_mod.guidToHandle(data.guid), true); + if (job.notify) |cb| if (job.notify_quiesced) { + cb.notify(cb.ctx, writer_mod.guidToHandle(data.guid), true); + cb.quiesceRelease(); + }; job.proto.quiesceRelease(); } if (push_dr) |dr| pushBuiltinSubscriptionCdr(self.alloc, dr, data); @@ -2660,6 +2698,7 @@ pub const DomainParticipantImpl = struct { proto: proto.ProtocolWriter, info: proto.MatchedReaderInfo, notify: ?MatchedNotify, + notify_quiesced: bool = false, remote_guid: Guid, unicast_locs: []const Locator, multicast_locs: []const Locator, @@ -2721,15 +2760,18 @@ pub const DomainParticipantImpl = struct { const uloc = dupeLocators(self.alloc, dr.unicast_locators); const mloc = dupeLocators(self.alloc, dr.multicast_locators); const info = self.buildMatchedReaderInfo(dr.guid, dr.qos, uloc, mloc); + const nq = if (aw.matched_notify) |cb| cb.quiesceAcquire() else false; jobs.append(self.alloc, .{ .proto = aw.proto, .info = info, .notify = aw.matched_notify, + .notify_quiesced = nq, .remote_guid = dr.guid, .unicast_locs = uloc, .multicast_locs = mloc, }) catch { aw.proto.quiesceRelease(); + if (nq) aw.matched_notify.?.quiesceRelease(); self.alloc.free(uloc); self.alloc.free(mloc); }; @@ -2740,7 +2782,10 @@ pub const DomainParticipantImpl = struct { } for (jobs.items) |job| { job.proto.addMatchedReader(&job.info) catch {}; - if (job.notify) |cb| cb.notify(cb.ctx, writer_mod.guidToHandle(job.remote_guid), true); + if (job.notify) |cb| if (job.notify_quiesced) { + cb.notify(cb.ctx, writer_mod.guidToHandle(job.remote_guid), true); + cb.quiesceRelease(); + }; job.proto.quiesceRelease(); self.alloc.free(job.unicast_locs); self.alloc.free(job.multicast_locs); @@ -2789,6 +2834,7 @@ pub const DomainParticipantImpl = struct { proto: proto.ProtocolReader, info: proto.MatchedWriterInfo, notify: ?MatchedNotify, + notify_quiesced: bool = false, remote_guid: Guid, unicast_locs: []const Locator, multicast_locs: []const Locator, @@ -2859,15 +2905,18 @@ pub const DomainParticipantImpl = struct { const uloc = dupeLocators(self.alloc, dw.unicast_locators); const mloc = dupeLocators(self.alloc, dw.multicast_locators); const info = buildMatchedWriterInfo(dw.guid, dw.qos, uloc, mloc); + const nq = if (ar.matched_notify) |cb| cb.quiesceAcquire() else false; jobs.append(self.alloc, .{ .proto = ar.proto, .info = info, .notify = ar.matched_notify, + .notify_quiesced = nq, .remote_guid = dw.guid, .unicast_locs = uloc, .multicast_locs = mloc, }) catch { ar.proto.quiesceRelease(); + if (nq) ar.matched_notify.?.quiesceRelease(); self.alloc.free(uloc); self.alloc.free(mloc); }; @@ -2878,7 +2927,10 @@ pub const DomainParticipantImpl = struct { } for (jobs.items) |job| { job.proto.addMatchedWriter(&job.info) catch {}; - if (job.notify) |cb| cb.notify(cb.ctx, writer_mod.guidToHandle(job.remote_guid), true); + if (job.notify) |cb| if (job.notify_quiesced) { + cb.notify(cb.ctx, writer_mod.guidToHandle(job.remote_guid), true); + cb.quiesceRelease(); + }; job.proto.quiesceRelease(); self.alloc.free(job.unicast_locs); self.alloc.free(job.multicast_locs); @@ -2932,7 +2984,7 @@ pub const DomainParticipantImpl = struct { var aw_it5 = self.active_writers.valueIterator(); while (aw_it5.next()) |aw| { if (aw.handle == handle) { - aw.matched_notify = .{ .ctx = notify_ctx, .notify = notify_fn }; + aw.matched_notify = .{ .ctx = notify_ctx, .notify = notify_fn, .is_reader = false }; break; } } @@ -2968,7 +3020,7 @@ pub const DomainParticipantImpl = struct { var ar_it5 = self.active_readers.valueIterator(); while (ar_it5.next()) |ar| { if (ar.handle == handle) { - ar.matched_notify = .{ .ctx = notify_ctx, .notify = notify_fn }; + ar.matched_notify = .{ .ctx = notify_ctx, .notify = notify_fn, .is_reader = true }; break; } } diff --git a/src/dcps/publisher.zig b/src/dcps/publisher.zig index fb513e95..4fb70947 100644 --- a/src/dcps/publisher.zig +++ b/src/dcps/publisher.zig @@ -20,6 +20,7 @@ const time_mod = @import("../util/time.zig"); const c_abi_handle = @import("../util/c_abi_handle.zig"); const ListenerBox = @import("../util/listener_box.zig").ListenerBox; const listener_fallback = @import("../util/listener_fallback.zig"); +const EntityQuiesce = @import("../util/entity_quiesce.zig").EntityQuiesce; const participant_mod = @import("participant.zig"); const config_mod = @import("../config/schema.zig"); const generated_config_mod = @import("../config/generated.zig"); @@ -147,6 +148,14 @@ pub const PublisherImpl = struct { /// `zidl/docs/design/binding-c-abi-identity.md`. c_abi: c_abi_handle.CachedCAbiHandle = .{}, + /// Deferred teardown, like DataWriterImpl's. A DataWriterImpl holds a + /// reference on its parent PublisherImpl for its whole (possibly + /// quiesce-deferred) lifetime, so `dispatchWriterFallback` -- reached + /// from a writer's discovery-driven `notifyPublicationMatched` after + /// `participant.mu` is released -- can never read a freed PublisherImpl + /// even if `delete_publisher` races it (DDS 1.4 listener-fallback chain). + quiesce: EntityQuiesce = .{}, + const Self = @This(); pub fn init( @@ -190,15 +199,41 @@ pub const PublisherImpl = struct { return self; } + /// Drops this publisher's own quiesce reference; `reallyDeinit` runs + /// immediately unless a child DataWriterImpl (or an in-flight fallback + /// dispatch) still holds one, in which case that release runs it. pub fn deinit(self: *Self) void { - self.listener_box.releaseRef(self.alloc); - if (self.status_cond) |sc| sc.deinit(); - self.c_abi.free(self.alloc); - // Destroy all remaining DataWriters. + // Tear owned DataWriters down synchronously first: each w.deinit() + // drops the lifetime ref it holds on us (writer.zig init), so by + // beginTeardown the only refs left are our own "alive" one and any + // still-in-flight fallback dispatch -- which is exactly what should + // keep us alive past this call. for (self.writers.items) |w| { self.cbs.destroy_proto_writer(self.cbs.ctx, w.instance_handle); w.deinit(); } + self.writers.clearRetainingCapacity(); + self.quiesce.beginTeardown(self, reallyDeinit); + } + + /// `*anyopaque` variants for the DataWriterImpl lifetime-ref and + /// participant.zig's fallback path (see DataWriterImpl.quiesceAcquireFn). + pub fn quiesceAcquireFn(ctx: *anyopaque) bool { + const self: *Self = @ptrCast(@alignCast(ctx)); + return self.quiesce.acquire(); + } + pub fn quiesceReleaseFn(ctx: *anyopaque) void { + const self: *Self = @ptrCast(@alignCast(ctx)); + self.quiesce.release(self, reallyDeinit); + } + + fn reallyDeinit(ctx: *anyopaque) void { + const self: *Self = @ptrCast(@alignCast(ctx)); + self.listener_box.releaseRef(self.alloc); + if (self.status_cond) |sc| sc.deinit(); + self.c_abi.free(self.alloc); + // Owned DataWriters were torn down synchronously in deinit(); the list + // is empty here. self.writers.deinit(self.alloc); self.qos.deinit(self.alloc); self.default_dw_qos.deinit(self.alloc); @@ -515,6 +550,11 @@ pub const PublisherImpl = struct { /// `deinit()`, including this one if it were mid-fallback, has /// returned). pub fn dispatchWriterFallback(self: *Self, comptime field: []const u8, bit: DDS.StatusMask, handle: anytype, args: anytype) bool { + // A child DataWriterImpl holds a lifetime ref (see `quiesce`), so + // reaching here means the struct is live; acquire keeps it so + // across the callback and refuses once teardown has begun. + if (!self.quiesce.acquire()) return false; + defer self.quiesce.release(self, reallyDeinit); const box = self.acquireListener(); defer box.releaseRef(self.alloc); if (listener_fallback.tryDispatch(field, self.listener_mask, bit, box.listener, handle, args)) return true; diff --git a/src/dcps/reader.zig b/src/dcps/reader.zig index 28591dea..27c9f869 100644 --- a/src/dcps/reader.zig +++ b/src/dcps/reader.zig @@ -219,6 +219,8 @@ pub const DataReaderImpl = struct { alloc: std.mem.Allocator, topic_desc: DDS.TopicDescription, subscriber: DDS.Subscriber, + /// See DataWriterImpl.parent_pinned. + parent_pinned: bool = false, proto_reader: proto.ProtocolReader, qos: DDS.DataReaderQos, listener_box: *ListenerBox(DDS.DataReaderListener), @@ -498,6 +500,11 @@ pub const DataReaderImpl = struct { getStatusFn, ); self.status_cond = sc; + // Lifetime ref on the parent SubscriberImpl (dropped in + // reallyDeinit): keeps subscriber.zig's `dispatchReaderFallback` + // from touching a SubscriberImpl freed by a racing + // delete_subscriber. See DataWriterImpl.init's mirror. + self.parent_pinned = !nil.isNil(subscriber) and subscriber_mod.SubscriberImpl.quiesceAcquireFn(subscriber.ptr); return self; } @@ -618,8 +625,10 @@ pub const DataReaderImpl = struct { self.qos.deinit(self.alloc); // NOTE: proto_reader lifecycle is owned by the participant (via // subDestroyProtoReader callback), not by DataReaderImpl. - // The participant's destroy_proto_reader callback frees it. + const pinned = self.parent_pinned; + const subscriber = self.subscriber; self.alloc.destroy(self); + if (pinned) subscriber_mod.SubscriberImpl.quiesceReleaseFn(subscriber.ptr); } /// Determine view_state and instance_state for an incoming change. diff --git a/src/dcps/subscriber.zig b/src/dcps/subscriber.zig index def75ce0..9aa0bb06 100644 --- a/src/dcps/subscriber.zig +++ b/src/dcps/subscriber.zig @@ -19,6 +19,7 @@ const time_mod = @import("../util/time.zig"); const c_abi_handle = @import("../util/c_abi_handle.zig"); const ListenerBox = @import("../util/listener_box.zig").ListenerBox; const listener_fallback = @import("../util/listener_fallback.zig"); +const EntityQuiesce = @import("../util/entity_quiesce.zig").EntityQuiesce; const participant_mod = @import("participant.zig"); const config_mod = @import("../config/schema.zig"); const generated_config_mod = @import("../config/generated.zig"); @@ -147,6 +148,13 @@ pub const SubscriberImpl = struct { /// `zidl/docs/design/binding-c-abi-identity.md`. c_abi: c_abi_handle.CachedCAbiHandle = .{}, + /// Deferred teardown, mirroring PublisherImpl: a DataReaderImpl holds a + /// lifetime reference on its parent SubscriberImpl so + /// `dispatchReaderFallback` -- reached from a reader's discovery-driven + /// `notifySubscriptionMatched` after `participant.mu` is released -- + /// never reads a SubscriberImpl freed by a racing delete_subscriber. + quiesce: EntityQuiesce = .{}, + const Self = @This(); pub fn init( @@ -188,13 +196,33 @@ pub const SubscriberImpl = struct { } pub fn deinit(self: *Self) void { - self.listener_box.releaseRef(self.alloc); - if (self.status_cond) |sc| sc.deinit(); - self.c_abi.free(self.alloc); + // Tear owned DataReaders down synchronously first (see PublisherImpl's + // matching comment): each r.deinit() drops the lifetime ref it holds + // on us, so by beginTeardown only our "alive" ref and any in-flight + // fallback dispatch remain. for (self.readers.items) |r| { self.cbs.destroy_proto_reader(self.cbs.ctx, r.instance_handle); r.deinit(); } + self.readers.clearRetainingCapacity(); + self.quiesce.beginTeardown(self, reallyDeinit); + } + + pub fn quiesceAcquireFn(ctx: *anyopaque) bool { + const self: *Self = @ptrCast(@alignCast(ctx)); + return self.quiesce.acquire(); + } + pub fn quiesceReleaseFn(ctx: *anyopaque) void { + const self: *Self = @ptrCast(@alignCast(ctx)); + self.quiesce.release(self, reallyDeinit); + } + + fn reallyDeinit(ctx: *anyopaque) void { + const self: *Self = @ptrCast(@alignCast(ctx)); + self.listener_box.releaseRef(self.alloc); + if (self.status_cond) |sc| sc.deinit(); + self.c_abi.free(self.alloc); + // Owned DataReaders were torn down synchronously in deinit(); empty here. self.readers.deinit(self.alloc); self.qos.deinit(self.alloc); self.default_dr_qos.deinit(self.alloc); @@ -569,6 +597,8 @@ pub const SubscriberImpl = struct { /// parent is guaranteed alive for the whole of this call — the same /// reasoning applies symmetrically here. pub fn dispatchReaderFallback(self: *Self, comptime field: []const u8, bit: DDS.StatusMask, handle: anytype, args: anytype) bool { + if (!self.quiesce.acquire()) return false; + defer self.quiesce.release(self, reallyDeinit); const box = self.acquireListener(); defer box.releaseRef(self.alloc); if (listener_fallback.tryDispatch(field, self.listener_mask, bit, box.listener, handle, args)) return true; diff --git a/src/dcps/writer.zig b/src/dcps/writer.zig index 3097c3d9..5e5e9317 100644 --- a/src/dcps/writer.zig +++ b/src/dcps/writer.zig @@ -66,6 +66,9 @@ pub const DataWriterImpl = struct { type_name: []const u8, // borrowed from TopicImpl topic: DDS.Topic, publisher: DDS.Publisher, + /// Whether init() took a lifetime quiesce ref on `publisher` (false for + /// hand-built impls in tests that skip init()). + parent_pinned: bool = false, proto_writer: proto.ProtocolWriter, qos: DDS.DataWriterQos, // Unified listener storage: both the base OMG `set_listener()` and the @@ -222,6 +225,12 @@ pub const DataWriterImpl = struct { getStatusFn, ); self.status_cond = sc; + // Hold a quiesce reference on the parent PublisherImpl for this + // writer's whole lifetime (dropped in reallyDeinit). Keeps + // publisher.zig's `dispatchWriterFallback` -- reached from this + // writer's discovery-driven `notifyPublicationMatched` -- from + // ever touching a PublisherImpl freed by a racing delete_publisher. + self.parent_pinned = !nil.isNil(publisher) and publisher_mod.PublisherImpl.quiesceAcquireFn(publisher.ptr); return self; } @@ -257,7 +266,13 @@ pub const DataWriterImpl = struct { self.key_registry.deinit(self.alloc); // NOTE: proto_writer lifecycle is owned by the participant (via // pubDestroyProtoWriter callback), not by DataWriterImpl. + const pinned = self.parent_pinned; + const publisher = self.publisher; self.alloc.destroy(self); + // Drop the parent-publisher lifetime ref taken in init(). After + // destroy(self) so a reentrant PublisherImpl.reallyDeinit (if this + // was its last ref) never sees this half-freed writer. + if (pinned) publisher_mod.PublisherImpl.quiesceReleaseFn(publisher.ptr); } /// Write a pre-serialized CDR payload (4-byte encap header + CDR bytes). diff --git a/stress-tests/README.md b/stress-tests/README.md new file mode 100644 index 00000000..28c41240 --- /dev/null +++ b/stress-tests/README.md @@ -0,0 +1,179 @@ +# zzdds stress tests + +Concurrency- and lifecycle-under-load tests, `OpenDDS EntityLifecycleStress`-shaped. +Deliberately **not** part of `zig build test`: these are wall-clock, real-concurrency, +real-UDP, non-deterministic by nature — the opposite of the deterministic +`ManualClock` / mock-transport gate (`docs/design/testing-strategy.md`). They live here, +build against the zzdds repo root as an out-of-tree package (like `examples/`), and run +from their own Python harness. + +Layout mirrors `examples/`: + +``` +stress-tests/ + run_all.py orchestrator — builds + runs everything (--strict for CI) + zig/ + entity_lifecycle_stress/ multi-process port of OpenDDS EntityLifecycleStress + lifecycle_churn/ in-process, N-thread entity/listener/waitset churn +``` + +Run everything: + +``` +cd zzdds && zig build -Dc-binding=false install # native Zig API only; no bindings needed +ZZDDS_ZIG_OUT="$PWD/zig-out" stress-tests/run_all.py # dev: skips what isn't built +ZZDDS_ZIG_OUT="$PWD/zig-out" stress-tests/run_all.py --strict # CI: any skip is a failure +``` + +Each `zig//` also has a standalone `run.py` and its own `zig build` (pinning +`../../..`), so a single scenario can be iterated in isolation. + +--- + +## Survey: OpenDDS `EntityLifecycleStress` + +`OpenDDS/tests/DCPS/EntityLifecycleStress/` — `run_test.pl` + `publisher.cpp` + +`subscriber.cpp`. + +**Harness.** `run_test.pl` spawns **10 publisher + 10 subscriber processes** by default +(`publishers N` / `subscribers M` override), *interleaved* — even loop index → pub, odd → +sub, wrapping — then starts them in that same interleaved order. All on **domain 31**. +`-DCPSPendingTimeout 3` caps how long a writer's teardown waits for unacknowledged +samples. 600 s overall timeout. + +**Publisher process.** `create_participant` → `register_type` → +`create_topic("Movie Discussion List")` → `create_publisher` → `create_datawriter` → +`register_instance` → write **750** small samples 10 ms apart (or **4** samples of 4000 +bytes, 2 s apart, with `-l`) → cleanup. + +**Subscriber process.** `create_participant` → `register_type` → `create_topic` → +`create_subscriber` → `create_datareader` (+ listener) → block on a condvar until *any* +`valid_data` sample is seen, ≤ 7.5 s → cleanup, with a **monitor thread** that prints +"taking a long time to clean up" if teardown exceeds 3 s. + +**The cleanup split** (both). `if (getpid() % 3) { delete_datawriter/reader; +delete_publisher/subscriber; }` — then **always** `delete_contained_entities()` + +`delete_participant()` + `TheServiceParticipant->shutdown()`. So ~⅓ of processes lean +entirely on the `delete_contained_entities` cascade; ~⅔ tear children down explicitly +first. + +**What it stresses.** N-participant concurrent SPDP/SEDP fan-in / fan-out on one domain; +overlapping create + teardown (interleaved start, staggered finish); both teardown code +paths; teardown while peers are still matching; slow / hung teardown detection. + +**What it doesn't.** Pass/fail is "every process exited 0 within 600 s" plus stdout +markers. It catches hangs and crashes; leaks / UAF / races only surface if you separately +run it under Valgrind / ASan. The cleanup path is chosen by `pid % 3` — non-deterministic +and not guaranteed to cover both. No structured metrics. + +--- + +## The zzdds port + +Two apps under `zig/`, both talking to the **native Zig API** (`zzdds.createFactory()`, +`dp.create_publisher(...)`, the generated typed `…DataWriter` / `…DataReader`) — no C-ABI, +no binding marshaling. Binding-marshaling stress is a separate concern (the Integration +tier in `docs/design/dcps-api-coverage-audit.md`). + +### `zig/entity_lifecycle_stress` — the faithful multi-process port + +One binary, `elc_stress`, with `--role pub|sub`. `run.py` spawns N pubs + M subs as +separate processes, interleaved, on a per-run-unique domain, bounded by +`_common.LiveProcess` (SIGINT → grace → SIGKILL; every wait has a ceiling). Entity graph, +keyed `Messenger` type, 750-small / 4-large sample profiles, subscriber-waits-for-first- +valid-sample, and teardown-duration monitoring are all faithful to OpenDDS. + +Where it improves on the original: + +| | OpenDDS | here | +|---|---|---| +| leak / double-free / UAF in teardown-under-churn | Valgrind, out of band | `-Ddebug-allocator=true` on the shared lib + `DebugAllocator` in the app → hard, attributable failure at exit | +| data races in the churn | — | `-Dsanitize-thread=true` build variant → gating | +| cleanup-path coverage | `pid % 3`, non-deterministic | `--cleanup {explicit,cascade}` flag; `run.py` always runs both | +| failure reproduction | — | `--seed`; every process prints a structured `SUMMARY: OK\|FAIL teardown_ms= entities_created=` line the harness asserts on | +| extra churn | — | `--churn` — create/delete a DataWriter/DataReader repeatedly on the live participant during active SEDP matching | + +### `zig/lifecycle_churn` — in-process, N-thread churn + +One binary, `churn_stress`, `DebugAllocator` as *the* allocator so any leak / double-free +/ UAF in churned teardown hard-fails at process exit. Generalizes the single hand-built +scenario in `test/dcps/participant_vtable_test.zig` ("reentrant delete_participant from a +timer-driven listener"). `--scenario`: + +- **`entities`** — many threads create → use → delete `DataWriter` / `DataReader` / + `Topic` / `Publisher` / `Subscriber` on shared participant(s) while SEDP matching is + active. (audit: *"rapid DataWriter/DataReader create/delete during active SEDP + matching"*) +- **`reentrant`** — the seed pattern run N-wide: a DEADLINE listener, fired from the + participant's own timer thread, reentrantly deletes its whole entity graph including + the participant. (audit: *"generalized reentrant-listener / entity-lifecycle churn"*) + +Planned follow-on scenarios (frame is built for them; not in this first pass): +`waitset` (threads attach/detach conditions on a shared WaitSet while another blocks in +`wait()`), `listener` (concurrent `set_listener()` replacement racing entity deletion and +event delivery), `instance` (`register_instance` / `unregister_instance` / `dispose` / +`get_key_value` / `lookup_instance` churn), `cft` (ContentFilteredTopic / QueryCondition / +`set_expression_parameters` churn). + +--- + +## CI + +`ci.yml` job `stress` (`needs: test-linux`), modeled on the `examples` job: builds zzdds +native + `-Ddebug-allocator`, then `stress-tests/run_all.py --strict` with **CI-sized** +parameters (small N, short durations) and a hard `timeout-minutes`. The weekly `schedule` +trigger runs a heavier matrix (larger N, longer runs, `--large`). Following +`examples-tsan`, a ThreadSanitizer variant of `lifecycle_churn` runs in that lane. + +## Findings + +### `lifecycle_churn --scenario entities` — UAF in the discovery-driven listener dispatch (found + fixed 2026-08-29) + +Found on the first run of the scenario. Originally ~40–60% repro with 4–6 churn threads +on one shared participant; 0% at 1–2 threads. Now 0 crashes in >100 runs at 8 threads; +the scenario is gating (not xfail). + +The churn threads do spec-legal work: concurrent `create`/`delete` of Publisher + +DataWriter (and Subscriber + DataReader). The participant's UDP receive thread, running +SEDP `onReaderDiscovered`, releases `participant.mu` and then fires a matched writer's +`on_publication_matched` — which walks the DDS 1.4 §2.2.4.1.5 listener-fallback chain +(writer → Publisher → Participant). A racing `delete_datawriter` + `delete_publisher` +freed the `DataWriterImpl` and then the `PublisherImpl` in that window, so +`notifyPublicationMatched` → `dispatchWriterFallback` → `box.releaseRef` ran on freed +memory. `entity_quiesce.zig`'s own doc spells out why: it "can't protect a `ctx` pointer +that was already dangling before `acquire()`" — that has to come from whatever hands the +callback its `ctx`, and the discovery path handed it a raw pointer with `mu` released. + +The `entities` scenario itself is the regression — it is gating in the `stress` CI job +(built with `-Ddebug-allocator`), and reproduces in ~10 s locally. It stays out of +`zig build test`: reproducing this race needs real UDP, a real receive thread, and two +racing teardown threads — inherently non-deterministic, the opposite of that suite's +`ManualClock` / mock-transport contract. + +**Fix, two parts** (`src/dcps/{participant,writer,reader,publisher,subscriber}.zig`): + +1. `MatchedNotify` gained `is_reader` + `quiesceAcquire`/`quiesceRelease`. The four + discovery-notify sites (`onReaderDiscovered`, `onWriterDiscovered`, the two + `announceMatched*`) now take the *target* `DataWriterImpl` / `DataReaderImpl`'s + `EntityQuiesce` reference **while `participant.mu` is held** and hold it across the + `notify` call — pinning the entity the raw `ctx` points at. +2. `PublisherImpl` / `SubscriberImpl` gained an `EntityQuiesce` (deferred teardown, like + the writer/reader already had). Each `DataWriterImpl` / `DataReaderImpl` holds a + lifetime reference on its parent for its whole (possibly-deferred) life, so + `dispatchWriterFallback` / `dispatchReaderFallback` can never read a freed parent even + if `delete_publisher` / `delete_subscriber` races. To avoid a ref cycle, the parent's + owned-children teardown moved from `reallyDeinit` into `deinit` (synchronous, before + `beginTeardown`). + +Participant-level fallback has the same shape but the `entities` scenario keeps the +participant stable, so it is untested here — a `--scenario` that also churns +participants would be the way to exercise it. + +## Non-goals + +Not a Bench-style configurable framework. Not a perf / throughput / latency benchmark. +Not cross-vendor (that is the dds-rtps matrix). Not part of `zig build test`. + +Any bug a stress test finds should be distilled into a **deterministic** Tier-1 / Tier-2 +regression under `zzdds/test/` — stress tests find, unit tests pin +(`docs/design/testing-strategy.md`). diff --git a/stress-tests/run_all.py b/stress-tests/run_all.py new file mode 100755 index 00000000..0b0e505c --- /dev/null +++ b/stress-tests/run_all.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Top-level stress-test orchestrator: builds + runs every stress app. + +Each app under zig// has its own run.py that builds it (zig build, +pinning ../../..) and drives its scenario. This just runs them in turn with +CI-sized parameters and aggregates pass/fail. Bigger/longer parameters and +--large belong in the nightly matrix, wired directly in ci.yml, not here. + +Usage: + stress-tests/run_all.py # dev + stress-tests/run_all.py --strict # CI: a missing zig toolchain is a failure + +Set ZZDDS_ZIG_OUT if the stress apps should link a specific build; by +default each app's `zig build` resolves zzdds from the repo root itself. +""" +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR.parent / "examples")) +import _common # noqa: E402 + +# (name, run.py path, CI args, xfail) +# xfail=True: run it, report the outcome, but don't fail the suite on it -- +# it's exercising a currently-known zzdds bug (see README "Findings"). It +# flips to a hard failure automatically once it starts passing. +APPS: list[tuple[str, Path, list[str], bool]] = [ + ( + "entity_lifecycle_stress", + SCRIPT_DIR / "zig" / "entity_lifecycle_stress" / "run.py", + ["--publishers", "4", "--subscribers", "4", "--samples", "300", "--churn", "--timeout", "120"], + False, + ), + ( + "lifecycle_churn/reentrant", + SCRIPT_DIR / "zig" / "lifecycle_churn" / "run.py", + ["--scenario", "reentrant", "--threads", "6", "--iterations", "40"], + False, + ), + ( + "lifecycle_churn/entities", + SCRIPT_DIR / "zig" / "lifecycle_churn" / "run.py", + ["--scenario", "entities", "--threads", "8", "--duration", "8"], + False, + ), +] + + +def main() -> int: + strict = "--strict" in sys.argv[1:] + unknown = [a for a in sys.argv[1:] if a != "--strict"] + if unknown: + print(f"unknown args: {unknown}", file=sys.stderr) + return 2 + + if shutil.which("zig") is None: + msg = "zig not found on PATH" + if strict: + print(f"FAIL (strict): {msg}", file=sys.stderr) + return 1 + print(f"SKIP: everything -- {msg}") + return 0 + + passed, failed, xfailed, xpassed = [], [], [], [] + for name, script, args, xfail in APPS: + print(f"\n== {name} =={' [xfail]' if xfail else ''}", flush=True) + rc = subprocess.run([sys.executable, str(script), *args]).returncode + if rc == 0: + (xpassed if xfail else passed).append(name) + else: + (xfailed if xfail else failed).append(name) + + print(f"\n{'='*52}") + for n in passed: + print(f" PASS {n}") + for n in xfailed: + print(f" XFAIL {n} (known bug -- see README Findings)") + for n in xpassed: + print(f" XPASS {n} (was expected to fail -- promote it to gating!)") + for n in failed: + print(f" FAIL {n}") + # XPASS is a soft nudge, not a failure -- flakiness shouldn't flip the gate. + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stress-tests/zig/entity_lifecycle_stress/build.zig b/stress-tests/zig/entity_lifecycle_stress/build.zig new file mode 100644 index 00000000..c5fa5d9c --- /dev/null +++ b/stress-tests/zig/entity_lifecycle_stress/build.zig @@ -0,0 +1,71 @@ +const std = @import("std"); + +// stress-tests/zig/entity_lifecycle_stress -- multi-process port of +// OpenDDS's tests/DCPS/EntityLifecycleStress. One binary, `elc_stress`, +// with --role pub|sub; run.py spawns N of each. Talks to zzdds's native +// Zig API directly (like examples/zig/hello_world). See ../../README.md. + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const sanitize_thread = b.option(bool, "sanitize-thread", "Enable ThreadSanitizer") orelse false; + // Zig 0.16's self-hosted backend silently no-ops sanitize_thread without + // this (confirmed upstream in zzdds's build.zig). + const use_llvm: ?bool = if (sanitize_thread) true else null; + + const zzdds_dep = b.dependency("zzdds", .{ + .target = target, + .optimize = optimize, + .@"sanitize-thread" = sanitize_thread, + }); + const zzdds_mod = zzdds_dep.module("zzdds"); + const zzdds_gen = zzdds_dep.module("zzdds_generated"); + const zzdds_ext_gen = zzdds_dep.module("zzdds_ext_generated"); + + // zidl executable + zidl_rt module come *through* zzdds (which re-exposes + // both) -- a second independent dependency on zidl breaks the build when + // zidl is a `.path` dep. See examples/zig/hello_world/build.zig. + const zidl_exe = zzdds_dep.artifact("zidl"); + const zidl_rt_mod = zzdds_dep.module("zidl_rt"); + + const gen = b.addRunArtifact(zidl_exe); + gen.addArgs(&.{ "-b", "zig", "--split-files", "--generate-zzdds-wrappers", "-o" }); + const gen_dir = gen.addOutputDirectoryArg("messenger-generated"); + gen.addFileArg(b.path("idl/messenger.idl")); + + const gen_mod = b.createModule(.{ + .root_source_file = gen_dir.path(b, "messenger.zig"), + .target = target, + .optimize = optimize, + .sanitize_thread = sanitize_thread, + .imports = &.{ + .{ .name = "zidl_rt", .module = zidl_rt_mod }, + .{ .name = "zzdds", .module = zzdds_mod }, + }, + }); + + const exe = b.addExecutable(.{ + .name = "elc_stress", + .use_llvm = use_llvm, + .root_module = b.createModule(.{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + .sanitize_thread = sanitize_thread, + .imports = &.{ + .{ .name = "zzdds", .module = zzdds_mod }, + .{ .name = "zzdds_generated", .module = zzdds_gen }, + .{ .name = "zzdds_ext_generated", .module = zzdds_ext_gen }, + .{ .name = "messenger_gen", .module = gen_mod }, + .{ .name = "zidl_rt", .module = zidl_rt_mod }, + }, + }), + }); + exe.root_module.link_libc = true; + b.installArtifact(exe); + + const run_step = b.step("run", "Run elc_stress (pass flags via -- ...)"); + const run_cmd = b.addRunArtifact(exe); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/stress-tests/zig/entity_lifecycle_stress/build.zig.zon b/stress-tests/zig/entity_lifecycle_stress/build.zig.zon new file mode 100644 index 00000000..d4a9975e --- /dev/null +++ b/stress-tests/zig/entity_lifecycle_stress/build.zig.zon @@ -0,0 +1,21 @@ +.{ + .name = .zzdds_stress_entity_lifecycle, + .version = "0.0.0", + .fingerprint = 0x7d11fdd8ab1c1646, + .minimum_zig_version = "0.16.0", + .dependencies = .{ + // Point at the zzdds repo root (this app lives at + // stress-tests/zig/entity_lifecycle_stress within it). No direct + // dependency on zidl -- build.zig gets the zidl executable and + // zidl_rt module through zzdds. See examples/zig/hello_world. + .zzdds = .{ + .path = "../../..", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "main.zig", + "idl", + }, +} diff --git a/stress-tests/zig/entity_lifecycle_stress/idl/messenger.idl b/stress-tests/zig/entity_lifecycle_stress/idl/messenger.idl new file mode 100644 index 00000000..bf981e4d --- /dev/null +++ b/stress-tests/zig/entity_lifecycle_stress/idl/messenger.idl @@ -0,0 +1,13 @@ +// Port of OpenDDS/tests/DCPS/ConsolidatedMessengerIdl/Messenger.idl, used by +// the EntityLifecycleStress port. `subject_id` is the key (OpenDDS marks it +// via `#pragma DCPS_DATA_KEY "Messenger::Message subject_id"`; the IDL4 form +// is `@key`). Bounded strings so the type has a fixed max size — the stress +// harness cares about entity churn, not payload variety. +@appendable +struct Message { + string<64> from; + string<64> subject; + @key int32 subject_id; + string<4096> text; + int32 count; +}; diff --git a/stress-tests/zig/entity_lifecycle_stress/main.zig b/stress-tests/zig/entity_lifecycle_stress/main.zig new file mode 100644 index 00000000..3a1f8a8a --- /dev/null +++ b/stress-tests/zig/entity_lifecycle_stress/main.zig @@ -0,0 +1,432 @@ +//! stress-tests/zig/entity_lifecycle_stress -- multi-process port of +//! OpenDDS's tests/DCPS/EntityLifecycleStress. +//! +//! One binary; `run.py` spawns N `--role pub` + M `--role sub` processes, +//! interleaved, on one (per-run-unique) domain. Each process stands up the +//! full entity graph, does its work (write 750 small / 4 large samples; +//! wait for the first valid sample), then tears down -- either explicitly +//! child-first or leaning on `delete_contained_entities()` -- while its +//! peers are still discovering/matching/leaving. +//! +//! Faithful to OpenDDS: the entity graph, the keyed `Message` type, the +//! sample profiles, subscriber-waits-for-first-valid-sample, and a monitor +//! thread that warns if teardown drags. Different from OpenDDS: the cleanup +//! path is a deterministic flag (not `pid % 3`), a `DebugAllocator` makes a +//! leak/UAF a hard exit failure, and every process ends with a structured +//! SUMMARY: OK role= teardown_ms= entities_created= samples= +//! line the harness asserts on. +//! +//! Any failure path prints a line starting "FAIL:" and exits nonzero. +//! Linux-only (uses getpid for the process label); the stress harness runs +//! on Linux CI. + +const std = @import("std"); +const zzdds = @import("zzdds"); +const DDS = @import("zzdds_generated").DDS; +const gen = @import("messenger_gen"); + +const TOPIC_NAME = "Movie Discussion List"; +const TYPE_NAME = "Message"; +const KEY_SUBJECT_ID: i32 = 16; + +// ── time (std.time.sleep / nanoTimestamp don't exist in this Zig) ──────────── + +fn monoNs(io: std.Io) i64 { + return @intCast(std.Io.Clock.awake.now(io).nanoseconds); +} + +fn sleepNs(io: std.Io, ns: u64) void { + (std.Io.Clock.Duration{ .raw = .{ .nanoseconds = @intCast(ns) }, .clock = .awake }).sleep(io) catch {}; +} + +// ── args ──────────────────────────────────────────────────────────────────── + +const Role = enum { writer, reader }; +const Cleanup = enum { explicit, cascade }; + +const Config = struct { + role: Role, + domain: u32 = 31, + samples: u32 = 750, + large: bool = false, + cleanup: Cleanup = .explicit, + churn: bool = false, + churn_iters: u32 = 20, + wait_ms: u32 = 7500, + slow_teardown_warn_ms: u32 = 3000, + seed: u64 = 0, +}; + +fn usage() noreturn { + std.debug.print( + \\usage: elc_stress --role pub|sub [options] + \\ --domain N DDS domain id (default 31) + \\ --samples N publisher: samples to write (default 750) + \\ --large publisher: 4 x 4000-byte samples, 2s apart + \\ --cleanup explicit|cascade explicit deletes children first; cascade + \\ relies on delete_contained_entities (default explicit) + \\ --churn after main work, create/delete a DW/DR on the + \\ live participant during teardown + \\ --churn-iters N churn loop count (default 20) + \\ --wait-ms N subscriber: wait for first valid sample (default 7500) + \\ --slow-teardown-warn-ms N monitor-thread warn threshold (default 3000) + \\ --seed N RNG seed for reproducible interleaving + \\ + , .{}); + std.process.exit(2); +} + +fn parseArgs(raw: std.process.Args) Config { + var it = std.process.Args.Iterator.init(raw); + _ = it.skip(); // program name + var role: ?Role = null; + var cfg = Config{ .role = undefined }; + while (it.next()) |a| { + if (std.mem.eql(u8, a, "--role")) { + const v = it.next() orelse usage(); + role = if (std.mem.eql(u8, v, "pub")) .writer else if (std.mem.eql(u8, v, "sub")) .reader else usage(); + } else if (std.mem.eql(u8, a, "--domain")) { + cfg.domain = std.fmt.parseInt(u32, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "--samples")) { + cfg.samples = std.fmt.parseInt(u32, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "--large")) { + cfg.large = true; + } else if (std.mem.eql(u8, a, "--cleanup")) { + const v = it.next() orelse usage(); + cfg.cleanup = if (std.mem.eql(u8, v, "explicit")) .explicit else if (std.mem.eql(u8, v, "cascade")) .cascade else usage(); + } else if (std.mem.eql(u8, a, "--churn")) { + cfg.churn = true; + } else if (std.mem.eql(u8, a, "--churn-iters")) { + cfg.churn_iters = std.fmt.parseInt(u32, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "--wait-ms")) { + cfg.wait_ms = std.fmt.parseInt(u32, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "--slow-teardown-warn-ms")) { + cfg.slow_teardown_warn_ms = std.fmt.parseInt(u32, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "--seed")) { + cfg.seed = std.fmt.parseInt(u64, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { + usage(); + } else { + std.debug.print("FAIL: unknown argument '{s}'\n", .{a}); + usage(); + } + } + cfg.role = role orelse usage(); + if (cfg.large) cfg.samples = 4; + return cfg; +} + +// ── teardown monitor (OpenDDS's cleanup_monitor thread) ───────────────────── + +const Monitor = struct { + io: std.Io, + started_ns: i64, + warn_after_ms: u32, + done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + + fn run(self: *Monitor) void { + var warned = false; + while (!self.done.load(.acquire)) { + sleepNs(self.io, std.time.ns_per_s); + if (self.done.load(.acquire)) break; + const elapsed_ms = @divTrunc(monoNs(self.io) - self.started_ns, std.time.ns_per_ms); + if (!warned and elapsed_ms >= self.warn_after_ms) { + warned = true; + std.debug.print("SLOW_TEARDOWN: still cleaning up after {d}ms\n", .{elapsed_ms}); + } + } + } +}; + +// ── subscriber listener state ────────────────────────────────────────────── + +const SubState = struct { + alloc: std.mem.Allocator, + valid_seen: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + count: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), +}; + +fn onDataAvailable(state: *SubState, dr: DDS.DataReader) void { + var reader = gen.MessageDataReader.init(dr, state.alloc); + while (true) { + var value: gen.Message = .{}; + var info: DDS.SampleInfo = .{}; + const got = reader.take_next_sample(&value, &info) catch { + std.debug.print("FAIL: take_next_sample() CDR error\n", .{}); + std.process.exit(1); + }; + if (!got) break; + if (!info.valid_data) continue; + _ = state.count.fetchAdd(1, .monotonic); + state.valid_seen.store(true, .release); + } +} + +// ── shared setup ─────────────────────────────────────────────────────────── + +const Setup = struct { + dpf: DDS.DomainParticipantFactory, + dp: DDS.DomainParticipant, + topic: DDS.Topic, +}; + +/// `factory` and `ts_alloc` are caller-owned storage that must outlive +/// everything (the TypeSupport ctx is a `*const std.mem.Allocator` the +/// generated key-hash path dereferences on every keyed write). +fn commonSetup( + factory: *zzdds.DomainParticipantFactory, + ts_alloc: *std.mem.Allocator, + cfg: Config, +) Setup { + factory.* = zzdds.createFactory() catch { + std.debug.print("FAIL: createFactory() failed\n", .{}); + std.process.exit(1); + }; + const dpf = factory.toDDSFactory(); + + const dp = dpf.create_participant(cfg.domain, .{}, null, 0); + if (dp.ptr == zzdds.dcps.NIL_PTR) { + std.debug.print("FAIL: create_participant() failed on domain {d}\n", .{cfg.domain}); + std.process.exit(1); + } + + if (!zzdds.registerTypeSupport(dp, TYPE_NAME, .{ + .ctx = @ptrCast(ts_alloc), + .compute_key_hash = gen.Message.computeKeyHashFromCdr, + })) { + std.debug.print("FAIL: registerTypeSupport() failed\n", .{}); + std.process.exit(1); + } + + const topic = dp.create_topic(TOPIC_NAME, TYPE_NAME, .{}, null, 0); + if (topic.ptr == zzdds.dcps.NIL_PTR) { + std.debug.print("FAIL: create_topic() failed\n", .{}); + std.process.exit(1); + } + return .{ .dpf = dpf, .dp = dp, .topic = topic }; +} + +// ── main ──────────────────────────────────────────────────────────────────── + +pub fn main(init: std.process.Init) !void { + const io = init.io; + var gpa = std.heap.DebugAllocator(.{}){}; + defer { + if (gpa.deinit() == .leak) { + std.debug.print("FAIL: DebugAllocator detected a leak at exit\n", .{}); + std.process.exit(1); + } + } + const alloc = gpa.allocator(); + + const cfg = parseArgs(init.minimal.args); + switch (cfg.role) { + .writer => try runPub(io, alloc, cfg), + .reader => try runSub(io, alloc, cfg), + } +} + +fn pid() i32 { + return std.os.linux.getpid(); +} + +fn runPub(io: std.Io, alloc: std.mem.Allocator, cfg: Config) !void { + var factory: zzdds.DomainParticipantFactory = undefined; + var ts_alloc = alloc; + const s = commonSetup(&factory, &ts_alloc, cfg); + + const publisher = s.dp.create_publisher(.{}, null, 0); + if (publisher.ptr == zzdds.dcps.NIL_PTR) { + std.debug.print("FAIL: create_publisher() failed\n", .{}); + std.process.exit(1); + } + + var dw_qos = DDS.DataWriterQos{}; + dw_qos.reliability.kind = .RELIABLE_RELIABILITY_QOS; + dw_qos.history.kind = .KEEP_LAST_HISTORY_QOS; + dw_qos.history.depth = 16; + + const dw = publisher.create_datawriter(s.topic, dw_qos, null, 0); + if (dw.ptr == zzdds.dcps.NIL_PTR) { + std.debug.print("FAIL: create_datawriter() failed\n", .{}); + std.process.exit(1); + } + var entities_created: u32 = 4; // participant, topic, publisher, datawriter + + const writer = gen.MessageDataWriter.init(dw, alloc); + + var msg = gen.Message{ .subject_id = KEY_SUBJECT_ID, .count = 0 }; + var from_buf: [32]u8 = undefined; + msg.from = @TypeOf(msg.from).fromSlice(std.fmt.bufPrint(&from_buf, "{d}", .{pid()}) catch "pub") catch .{}; + msg.subject = @TypeOf(msg.subject).fromSlice("Review") catch .{}; + const large_text = [_]u8{'Z'} ** 4000; + msg.text = @TypeOf(msg.text).fromSlice(if (cfg.large) large_text[0..] else "Wash. Rinse. Repeat.") catch .{}; + + const handle = writer.register_instance(msg); + + const delay_ns: u64 = if (cfg.large) 2 * std.time.ns_per_s else 10 * std.time.ns_per_ms; + var i: u32 = 0; + while (i < cfg.samples) : (i += 1) { + sleepNs(io, delay_ns); + msg.count += 1; + writer.write(msg, handle) catch { + std.debug.print("FAIL: write() failed at count={d}\n", .{msg.count}); + std.process.exit(1); + }; + } + std.debug.print("Publisher {d} is done. Exiting.\n", .{pid()}); + + if (cfg.churn) entities_created += churnWriter(io, s.dp, s.topic, cfg.churn_iters); + + const teardown_ms = teardown(io, .{ + .cfg = cfg, + .factory = &factory, + .dpf = s.dpf, + .dp = s.dp, + .topic = s.topic, + .kind = .{ .writer = .{ .publisher = publisher, .dw = dw } }, + }); + + std.debug.print( + "SUMMARY: OK role=pub teardown_ms={d} entities_created={d} samples={d}\n", + .{ teardown_ms, entities_created, cfg.samples }, + ); +} + +fn runSub(io: std.Io, alloc: std.mem.Allocator, cfg: Config) !void { + var factory: zzdds.DomainParticipantFactory = undefined; + var ts_alloc = alloc; + const s = commonSetup(&factory, &ts_alloc, cfg); + + const subscriber = s.dp.create_subscriber(.{}, null, 0); + if (subscriber.ptr == zzdds.dcps.NIL_PTR) { + std.debug.print("FAIL: create_subscriber() failed\n", .{}); + std.process.exit(1); + } + + var dr_qos = DDS.DataReaderQos{}; + dr_qos.reliability.kind = .RELIABLE_RELIABILITY_QOS; + dr_qos.history.kind = .KEEP_LAST_HISTORY_QOS; + dr_qos.history.depth = 16; + + var state = SubState{ .alloc = alloc }; + const listener = DDS.dataReaderListener(&state, .{ .on_data_available = onDataAvailable }); + + const topic_desc = s.dp.lookup_topicdescription(TOPIC_NAME); + const dr = subscriber.create_datareader(topic_desc, dr_qos, listener, DDS.DATA_AVAILABLE_STATUS); + if (dr.ptr == zzdds.dcps.NIL_PTR) { + std.debug.print("FAIL: create_datareader() failed\n", .{}); + std.process.exit(1); + } + var entities_created: u32 = 4; // participant, topic, subscriber, datareader + + const deadline = monoNs(io) + @as(i64, cfg.wait_ms) * std.time.ns_per_ms; + while (!state.valid_seen.load(.acquire)) { + if (monoNs(io) > deadline) { + std.debug.print("FAIL: no valid sample within {d}ms\n", .{cfg.wait_ms}); + std.process.exit(1); + } + sleepNs(io, 20 * std.time.ns_per_ms); + } + std.debug.print("Subscriber {d} got new message data. Exiting.\n", .{pid()}); + + if (cfg.churn) entities_created += churnReader(io, s.dp, cfg.churn_iters); + + const teardown_ms = teardown(io, .{ + .cfg = cfg, + .factory = &factory, + .dpf = s.dpf, + .dp = s.dp, + .topic = s.topic, + .kind = .{ .reader = .{ .subscriber = subscriber, .dr = dr } }, + }); + + std.debug.print( + "SUMMARY: OK role=sub teardown_ms={d} entities_created={d} samples={d}\n", + .{ teardown_ms, entities_created, state.count.load(.monotonic) }, + ); +} + +// ── churn: create/delete a child entity repeatedly on the live participant ── + +fn churnWriter(io: std.Io, dp: DDS.DomainParticipant, topic: DDS.Topic, iters: u32) u32 { + var made: u32 = 0; + var qos = DDS.DataWriterQos{}; + qos.reliability.kind = .RELIABLE_RELIABILITY_QOS; + var k: u32 = 0; + while (k < iters) : (k += 1) { + const p = dp.create_publisher(.{}, null, 0); + if (p.ptr == zzdds.dcps.NIL_PTR) continue; + const w = p.create_datawriter(topic, qos, null, 0); + if (w.ptr != zzdds.dcps.NIL_PTR) { + made += 2; + _ = p.delete_datawriter(w); + } + _ = dp.delete_publisher(p); + sleepNs(io, 2 * std.time.ns_per_ms); + } + return made; +} + +fn churnReader(io: std.Io, dp: DDS.DomainParticipant, iters: u32) u32 { + var made: u32 = 0; + var qos = DDS.DataReaderQos{}; + qos.reliability.kind = .RELIABLE_RELIABILITY_QOS; + const td = dp.lookup_topicdescription(TOPIC_NAME); + var k: u32 = 0; + while (k < iters) : (k += 1) { + const sub = dp.create_subscriber(.{}, null, 0); + if (sub.ptr == zzdds.dcps.NIL_PTR) continue; + const r = sub.create_datareader(td, qos, null, 0); + if (r.ptr != zzdds.dcps.NIL_PTR) { + made += 2; + _ = sub.delete_datareader(r); + } + _ = dp.delete_subscriber(sub); + sleepNs(io, 2 * std.time.ns_per_ms); + } + return made; +} + +// ── teardown (timed, monitored) ──────────────────────────────────────────── + +const TeardownArgs = struct { + cfg: Config, + factory: *zzdds.DomainParticipantFactory, + dpf: DDS.DomainParticipantFactory, + dp: DDS.DomainParticipant, + topic: DDS.Topic, + kind: union(enum) { + writer: struct { publisher: DDS.Publisher, dw: DDS.DataWriter }, + reader: struct { subscriber: DDS.Subscriber, dr: DDS.DataReader }, + }, +}; + +fn teardown(io: std.Io, a: TeardownArgs) i64 { + const start = monoNs(io); + var mon = Monitor{ .io = io, .started_ns = start, .warn_after_ms = a.cfg.slow_teardown_warn_ms }; + const mon_thread = std.Thread.spawn(.{}, Monitor.run, .{&mon}) catch null; + + if (a.cfg.cleanup == .explicit) { + switch (a.kind) { + .writer => |p| { + _ = p.publisher.delete_datawriter(p.dw); + _ = a.dp.delete_publisher(p.publisher); + }, + .reader => |sub| { + _ = sub.subscriber.delete_datareader(sub.dr); + _ = a.dp.delete_subscriber(sub.subscriber); + }, + } + _ = a.dp.delete_topic(a.topic); + } + + _ = a.dp.delete_contained_entities(); + _ = a.dpf.delete_participant(a.dp); + a.factory.deinit(); + + mon.done.store(true, .release); + if (mon_thread) |t| t.join(); + + return @divTrunc(monoNs(io) - start, std.time.ns_per_ms); +} diff --git a/stress-tests/zig/entity_lifecycle_stress/run.py b/stress-tests/zig/entity_lifecycle_stress/run.py new file mode 100755 index 00000000..6483da7b --- /dev/null +++ b/stress-tests/zig/entity_lifecycle_stress/run.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Build + run the EntityLifecycleStress port. + +Spawns N `--role pub` + M `--role sub` `elc_stress` processes, interleaved +(pub, sub, pub, sub, ...) the way OpenDDS's run_test.pl does, all on one +per-run-unique domain. Every process is bounded by _common.LiveProcess +(SIGINT -> grace -> SIGKILL; every wait has a ceiling), so a hung teardown +can never hang this script -- it's caught by the per-process timeout and +reported. + +Pass/fail: + * every process exits 0 within the timeout + * every process prints exactly one `SUMMARY: OK ...` line + * no `FAIL:` / `panic` / `SLOW_TEARDOWN`-over-hard-limit line anywhere + * every process's self-reported teardown_ms < --teardown-hard-limit-ms + +CI runs this with small counts; the nightly matrix runs it bigger and with +--large. Both --cleanup modes are always exercised. +""" +from __future__ import annotations + +import argparse +import os +import random +import re +import sys +from pathlib import Path + +APP_DIR = Path(__file__).resolve().parent +# stress-tests/zig//run.py -> repo root is parents[2]; reuse examples/_common.py +sys.path.insert(0, str(APP_DIR.parents[2] / "examples")) +import _common # noqa: E402 + +SUMMARY_RE = re.compile(r"^SUMMARY: OK role=(pub|sub) teardown_ms=(\d+) ", re.M) + + +def build() -> Path: + log = _common.mktemp_logdir("elc-stress") / "build.log" + ok = _common.run_build(["zig", "build"], cwd=APP_DIR, log_path=log) + if not ok: + print(_common.Path(log).read_text(errors="replace"), file=sys.stderr) + raise SystemExit("FAIL: elc_stress build failed") + return APP_DIR / "zig-out" / "bin" / "elc_stress" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--publishers", type=int, default=4) + ap.add_argument("--subscribers", type=int, default=4) + ap.add_argument("--samples", type=int, default=400) + ap.add_argument("--large", action="store_true") + ap.add_argument("--churn", action="store_true") + ap.add_argument("--domain", type=int, default=0, help="0 = pick a random high domain") + ap.add_argument("--timeout", type=int, default=120, help="per-process wall-clock ceiling (s)") + ap.add_argument("--teardown-hard-limit-ms", type=int, default=15000) + ap.add_argument("--seed", type=int, default=0) + args = ap.parse_args() + + binary = build() + seed = args.seed or random.randrange(1, 2**31) + rng = random.Random(seed) + # 1..232 keeps every derived RTPS port < 65536; steer clear of 0. + domain = args.domain or rng.randint(2, 230) + logdir = _common.mktemp_logdir("elc-stress-run") + print(f"domain={domain} seed={seed} pubs={args.publishers} subs={args.subscribers} " + f"samples={args.samples} large={args.large} churn={args.churn} logs={logdir}") + + # Interleaved spec list (pub, sub, pub, sub, ...), wrapping when one side + # runs out -- exactly run_test.pl's ordering. + specs: list[tuple[str, int]] = [] + pi = si = 0 + for i in range(args.publishers + args.subscribers): + want_pub = (i % 2 == 0) + if want_pub and pi < args.publishers: + specs.append(("pub", pi)); pi += 1 + elif si < args.subscribers: + specs.append(("sub", si)); si += 1 + else: + specs.append(("pub", pi)); pi += 1 + + procs: list[tuple[str, _common.LiveProcess]] = [] + for idx, (role, n) in enumerate(specs): + # Deterministic cleanup-mode split: every 3rd process leans on the + # delete_contained_entities cascade, the rest tear down explicitly + # (OpenDDS's `pid % 3`, made reproducible). + cleanup = "cascade" if idx % 3 == 0 else "explicit" + cmd = [str(binary), "--role", role, "--domain", str(domain), + "--cleanup", cleanup, "--seed", str(seed), + "--slow-teardown-warn-ms", "3000"] + if role == "pub": + cmd += ["--samples", str(args.samples)] + if args.large: + cmd.append("--large") + else: + cmd += ["--wait-ms", str(max(8000, args.samples * 10 + 5000))] + if args.churn: + cmd.append("--churn") + name = f"{role}_{n}" + procs.append((name, _common.LiveProcess(cmd, log_path=logdir / f"{name}.log"))) + + failures: list[str] = [] + for name, lp in procs: + rc = lp.stop(grace=10) if lp.wait(timeout=args.timeout) is None else lp.proc.returncode + text = lp.log_text() + if rc != 0: + failures.append(f"{name}: exit {rc}") + m = SUMMARY_RE.search(text) + if not m: + failures.append(f"{name}: no `SUMMARY: OK` line") + else: + td = int(m.group(2)) + if td > args.teardown_hard_limit_ms: + failures.append(f"{name}: teardown_ms={td} > {args.teardown_hard_limit_ms}") + for bad in ("FAIL:", "panic", "Segmentation fault", "leak"): + if bad in text: + failures.append(f"{name}: log contains {bad!r}") + break + + if failures: + print("\n== FAILURES ==", file=sys.stderr) + for f in failures: + print(f" {f}", file=sys.stderr) + print(f"\nLogs kept at {logdir}", file=sys.stderr) + return 1 + print(f"OK: {len(procs)} processes, all clean (domain {domain}, seed {seed})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stress-tests/zig/lifecycle_churn/build.zig b/stress-tests/zig/lifecycle_churn/build.zig new file mode 100644 index 00000000..78c5d71b --- /dev/null +++ b/stress-tests/zig/lifecycle_churn/build.zig @@ -0,0 +1,72 @@ +const std = @import("std"); + +// stress-tests/zig/lifecycle_churn -- in-process, N-thread entity / +// listener / waitset churn. One binary, `churn_stress`, with --scenario +// {entities,reentrant,...}. DebugAllocator is *the* allocator so a leak / +// double-free / UAF in churned teardown hard-fails at exit. Talks to +// zzdds's native Zig API directly. See ../../README.md. + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const sanitize_thread = b.option(bool, "sanitize-thread", "Enable ThreadSanitizer") orelse false; + // Zig 0.16's self-hosted backend silently no-ops sanitize_thread without + // this (confirmed upstream in zzdds's build.zig). + const use_llvm: ?bool = if (sanitize_thread) true else null; + + const zzdds_dep = b.dependency("zzdds", .{ + .target = target, + .optimize = optimize, + .@"sanitize-thread" = sanitize_thread, + }); + const zzdds_mod = zzdds_dep.module("zzdds"); + const zzdds_gen = zzdds_dep.module("zzdds_generated"); + const zzdds_ext_gen = zzdds_dep.module("zzdds_ext_generated"); + + // zidl executable + zidl_rt module come *through* zzdds (which re-exposes + // both) -- a second independent dependency on zidl breaks the build when + // zidl is a `.path` dep. See examples/zig/hello_world/build.zig. + const zidl_exe = zzdds_dep.artifact("zidl"); + const zidl_rt_mod = zzdds_dep.module("zidl_rt"); + + const gen = b.addRunArtifact(zidl_exe); + gen.addArgs(&.{ "-b", "zig", "--split-files", "--generate-zzdds-wrappers", "-o" }); + const gen_dir = gen.addOutputDirectoryArg("messenger-generated"); + gen.addFileArg(b.path("idl/messenger.idl")); + + const gen_mod = b.createModule(.{ + .root_source_file = gen_dir.path(b, "messenger.zig"), + .target = target, + .optimize = optimize, + .sanitize_thread = sanitize_thread, + .imports = &.{ + .{ .name = "zidl_rt", .module = zidl_rt_mod }, + .{ .name = "zzdds", .module = zzdds_mod }, + }, + }); + + const exe = b.addExecutable(.{ + .name = "churn_stress", + .use_llvm = use_llvm, + .root_module = b.createModule(.{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + .sanitize_thread = sanitize_thread, + .imports = &.{ + .{ .name = "zzdds", .module = zzdds_mod }, + .{ .name = "zzdds_generated", .module = zzdds_gen }, + .{ .name = "zzdds_ext_generated", .module = zzdds_ext_gen }, + .{ .name = "messenger_gen", .module = gen_mod }, + .{ .name = "zidl_rt", .module = zidl_rt_mod }, + }, + }), + }); + exe.root_module.link_libc = true; + b.installArtifact(exe); + + const run_step = b.step("run", "Run churn_stress (pass flags via -- ...)"); + const run_cmd = b.addRunArtifact(exe); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/stress-tests/zig/lifecycle_churn/build.zig.zon b/stress-tests/zig/lifecycle_churn/build.zig.zon new file mode 100644 index 00000000..8e924a22 --- /dev/null +++ b/stress-tests/zig/lifecycle_churn/build.zig.zon @@ -0,0 +1,21 @@ +.{ + .name = .zzdds_stress_lifecycle_churn, + .version = "0.0.0", + .fingerprint = 0x9c76ed15fdbdc3e3, + .minimum_zig_version = "0.16.0", + .dependencies = .{ + // Point at the zzdds repo root (this app lives at + // stress-tests/zig/lifecycle_churn within it). No direct + // dependency on zidl -- build.zig gets the zidl executable and + // zidl_rt module through zzdds. See examples/zig/hello_world. + .zzdds = .{ + .path = "../../..", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "main.zig", + "idl", + }, +} diff --git a/stress-tests/zig/lifecycle_churn/idl/messenger.idl b/stress-tests/zig/lifecycle_churn/idl/messenger.idl new file mode 100644 index 00000000..bf981e4d --- /dev/null +++ b/stress-tests/zig/lifecycle_churn/idl/messenger.idl @@ -0,0 +1,13 @@ +// Port of OpenDDS/tests/DCPS/ConsolidatedMessengerIdl/Messenger.idl, used by +// the EntityLifecycleStress port. `subject_id` is the key (OpenDDS marks it +// via `#pragma DCPS_DATA_KEY "Messenger::Message subject_id"`; the IDL4 form +// is `@key`). Bounded strings so the type has a fixed max size — the stress +// harness cares about entity churn, not payload variety. +@appendable +struct Message { + string<64> from; + string<64> subject; + @key int32 subject_id; + string<4096> text; + int32 count; +}; diff --git a/stress-tests/zig/lifecycle_churn/main.zig b/stress-tests/zig/lifecycle_churn/main.zig new file mode 100644 index 00000000..8d51312c --- /dev/null +++ b/stress-tests/zig/lifecycle_churn/main.zig @@ -0,0 +1,301 @@ +//! stress-tests/zig/lifecycle_churn -- in-process, N-thread entity / +//! listener / lifecycle churn. Generalizes the single hand-built scenario +//! in zzdds/test/dcps/participant_vtable_test.zig ("reentrant +//! delete_participant from a timer-driven listener") into a load test. +//! +//! `DebugAllocator` is *the* allocator: any leak / double-free / UAF in +//! churned teardown fails the process at exit. Runs standalone; run.py +//! sweeps scenarios and (in CI) also builds it once under +//! `-Dsanitize-thread` for the data-race lane. +//! +//! Scenarios: +//! entities -- N threads create -> use -> delete DataWriter/DataReader/ +//! Publisher/Subscriber on a shared participant while SEDP +//! matching is active, for --duration seconds. +//! reentrant -- N threads, --iterations each: stand up participant + +//! publisher + datawriter whose DEADLINE listener (fired +//! from the participant's own timer thread) reentrantly +//! deletes the whole graph including the participant. +//! +//! Ends with `SUMMARY: OK scenario= ops=` or a `FAIL:`/panic + exit 1. + +const std = @import("std"); +const zzdds = @import("zzdds"); +const DDS = @import("zzdds_generated").DDS; +const gen = @import("messenger_gen"); + +const TYPE_NAME = "Message"; + +fn sleepMs(io: std.Io, ms: u64) void { + (std.Io.Clock.Duration{ .raw = .{ .nanoseconds = @intCast(ms * std.time.ns_per_ms) }, .clock = .awake }).sleep(io) catch {}; +} +fn monoNs(io: std.Io) i64 { + return @intCast(std.Io.Clock.awake.now(io).nanoseconds); +} + +const Scenario = enum { entities, reentrant }; + +const Config = struct { + scenario: Scenario, + threads: u32 = 6, + iterations: u32 = 40, + duration_s: u32 = 8, + domain: u32 = 71, + seed: u64 = 0, +}; + +fn usage() noreturn { + std.debug.print( + \\usage: churn_stress --scenario entities|reentrant [options] + \\ --threads N concurrent churn threads (default 6) + \\ --iterations N reentrant: cycles per thread (default 40) + \\ --duration N entities: seconds to churn (default 8) + \\ --domain N DDS domain id (default 71) + \\ --seed N RNG seed + \\ + , .{}); + std.process.exit(2); +} + +fn parseArgs(raw: std.process.Args) Config { + var it = std.process.Args.Iterator.init(raw); + _ = it.skip(); + var scenario: ?Scenario = null; + var cfg = Config{ .scenario = undefined }; + while (it.next()) |a| { + if (std.mem.eql(u8, a, "--scenario")) { + const v = it.next() orelse usage(); + scenario = if (std.mem.eql(u8, v, "entities")) .entities else if (std.mem.eql(u8, v, "reentrant")) .reentrant else usage(); + } else if (std.mem.eql(u8, a, "--threads")) { + cfg.threads = std.fmt.parseInt(u32, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "--iterations")) { + cfg.iterations = std.fmt.parseInt(u32, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "--duration")) { + cfg.duration_s = std.fmt.parseInt(u32, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "--domain")) { + cfg.domain = std.fmt.parseInt(u32, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "--seed")) { + cfg.seed = std.fmt.parseInt(u64, it.next() orelse usage(), 10) catch usage(); + } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { + usage(); + } else { + std.debug.print("FAIL: unknown argument '{s}'\n", .{a}); + usage(); + } + } + cfg.scenario = scenario orelse usage(); + return cfg; +} + +var g_alloc: std.mem.Allocator = undefined; +var g_ts_alloc: std.mem.Allocator = undefined; +var g_ops = std.atomic.Value(u64).init(0); +var g_fail = std.atomic.Value(bool).init(false); + +fn fail(comptime msg: []const u8) void { + std.debug.print("FAIL: " ++ msg ++ "\n", .{}); + g_fail.store(true, .release); +} + +pub fn main(init: std.process.Init) !void { + const io = init.io; + var gpa = std.heap.DebugAllocator(.{}){}; + defer { + if (gpa.deinit() == .leak) { + std.debug.print("FAIL: DebugAllocator detected a leak at exit\n", .{}); + std.process.exit(1); + } + } + g_alloc = gpa.allocator(); + g_ts_alloc = g_alloc; + + const cfg = parseArgs(init.minimal.args); + + switch (cfg.scenario) { + .entities => try runEntities(io, cfg), + .reentrant => try runReentrant(io, cfg), + } + + if (g_fail.load(.acquire)) std.process.exit(1); + std.debug.print("SUMMARY: OK scenario={s} ops={d}\n", .{ @tagName(cfg.scenario), g_ops.load(.monotonic) }); +} + +// ── scenario: entities ───────────────────────────────────────────────────── +// +// One shared participant + topic. N threads each spin create -> delete of a +// Publisher+DataWriter or Subscriber+DataReader while the others do the +// same, so SEDP is continuously (un)matching. A leak or UAF in any teardown +// path shows up as a DebugAllocator failure or a crash. + +const EntitiesCtx = struct { + io: std.Io, + dp: DDS.DomainParticipant, + topic: DDS.Topic, + td: DDS.TopicDescription, + deadline_ns: i64, + idx: u32, +}; + +fn entitiesThread(ctx: EntitiesCtx) void { + var local: u64 = 0; + while (monoNs(ctx.io) < ctx.deadline_ns and !g_fail.load(.acquire)) { + if (ctx.idx % 2 == 0) { + const p = ctx.dp.create_publisher(.{}, null, 0); + if (p.ptr == zzdds.dcps.NIL_PTR) { + fail("create_publisher returned nil under churn"); + return; + } + var qos = DDS.DataWriterQos{}; + qos.reliability.kind = .RELIABLE_RELIABILITY_QOS; + const w = p.create_datawriter(ctx.topic, qos, null, 0); + if (w.ptr != zzdds.dcps.NIL_PTR) _ = p.delete_datawriter(w); + _ = ctx.dp.delete_publisher(p); + } else { + const sub = ctx.dp.create_subscriber(.{}, null, 0); + if (sub.ptr == zzdds.dcps.NIL_PTR) { + fail("create_subscriber returned nil under churn"); + return; + } + var qos = DDS.DataReaderQos{}; + qos.reliability.kind = .RELIABLE_RELIABILITY_QOS; + const r = sub.create_datareader(ctx.td, qos, null, 0); + if (r.ptr != zzdds.dcps.NIL_PTR) _ = sub.delete_datareader(r); + _ = ctx.dp.delete_subscriber(sub); + } + local += 1; + sleepMs(ctx.io, 1); + } + _ = g_ops.fetchAdd(local, .monotonic); +} + +fn runEntities(io: std.Io, cfg: Config) !void { + var factory = zzdds.createFactory() catch { + fail("createFactory"); + return; + }; + defer factory.deinit(); + const dpf = factory.toDDSFactory(); + + const dp = dpf.create_participant(cfg.domain, .{}, null, 0); + if (dp.ptr == zzdds.dcps.NIL_PTR) { + fail("create_participant"); + return; + } + defer _ = dpf.delete_participant(dp); + + if (!zzdds.registerTypeSupport(dp, TYPE_NAME, .{ + .ctx = @ptrCast(&g_ts_alloc), + .compute_key_hash = gen.Message.computeKeyHashFromCdr, + })) { + fail("registerTypeSupport"); + return; + } + + const topic = dp.create_topic("ChurnTopic", TYPE_NAME, .{}, null, 0); + if (topic.ptr == zzdds.dcps.NIL_PTR) { + fail("create_topic"); + return; + } + defer _ = dp.delete_topic(topic); + const td = dp.lookup_topicdescription("ChurnTopic"); + + const deadline_ns = monoNs(io) + @as(i64, cfg.duration_s) * std.time.ns_per_s; + + const threads = try g_alloc.alloc(std.Thread, cfg.threads); + defer g_alloc.free(threads); + for (threads, 0..) |*t, i| { + t.* = try std.Thread.spawn(.{}, entitiesThread, .{EntitiesCtx{ + .io = io, + .dp = dp, + .topic = topic, + .td = td, + .deadline_ns = deadline_ns, + .idx = @intCast(i), + }}); + } + for (threads) |t| t.join(); +} + +// ── scenario: reentrant ──────────────────────────────────────────────────── +// +// The seed test, run --threads-wide and --iterations-deep. Each cycle: a +// DEADLINE listener fired from the participant's own timer thread reentrantly +// deletes datawriter -> publisher -> topic -> participant. The teardown +// therefore runs *on the timer thread deinit() is trying to stop* -- the +// exact self-join / self-free hazard. + +const ReentrantCtx = struct { + factory: DDS.DomainParticipantFactory, + dp: DDS.DomainParticipant, + pub_: DDS.Publisher, + topic: DDS.Topic, + dw: DDS.DataWriter, + ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + fired: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), +}; + +fn onDeadlineMissed(_: *anyopaque, _: *const DDS.OfferedDeadlineMissedStatus, ld: ?*anyopaque) callconv(.c) void { + const ctx: *ReentrantCtx = @ptrCast(@alignCast(ld)); + if (!ctx.ready.load(.acquire)) return; + if (ctx.fired.swap(true, .acq_rel)) return; + _ = ctx.pub_.delete_datawriter(ctx.dw); + _ = ctx.dp.delete_publisher(ctx.pub_); + _ = ctx.dp.delete_topic(ctx.topic); + _ = ctx.factory.delete_participant(ctx.dp); + ctx.done.store(true, .release); +} + +fn reentrantThread(io: std.Io, cfg: Config) void { + var it: u32 = 0; + while (it < cfg.iterations and !g_fail.load(.acquire)) : (it += 1) { + var factory = zzdds.createFactory() catch { + fail("reentrant: createFactory"); + return; + }; + const dpf = factory.toDDSFactory(); + const dp = dpf.create_participant(cfg.domain, .{}, null, 0); + if (dp.ptr == zzdds.dcps.NIL_PTR) { + fail("reentrant: create_participant"); + factory.deinit(); + return; + } + const pub_ = dp.create_publisher(.{}, null, 0); + const topic = dp.create_topic("ReentrantTopic", TYPE_NAME, .{}, null, 0); + + var ctx = ReentrantCtx{ .factory = dpf, .dp = dp, .pub_ = pub_, .topic = topic, .dw = undefined }; + var dw_qos = DDS.DataWriterQos{}; + dw_qos.deadline.period = .{ .sec = 0, .nanosec = 50_000_000 }; // 50ms + const dw = pub_.create_datawriter(topic, dw_qos, DDS.DataWriterListener{ + .listener_data = &ctx, + .on_offered_deadline_missed = onDeadlineMissed, + }, DDS.OFFERED_DEADLINE_MISSED_STATUS); + ctx.dw = dw; + ctx.ready.store(true, .release); + + const deadline = monoNs(io) + 5 * std.time.ns_per_s; + while (!ctx.done.load(.acquire)) { + if (monoNs(io) >= deadline) { + fail("reentrant: listener never fired within 5s (self-join deadlock?)"); + return; + } + sleepMs(io, 10); + } + // The detached timer thread still has a little unwinding + its own + // alloc.destroy(self) to do after ctx.done flips; give it room so + // the next iteration doesn't race it on the shared DebugAllocator. + // Generous, not tight. + sleepMs(io, 50); + // The listener deleted the participant; this destroys the now-empty + // FactoryOwner. (In the seed unit test this is `defer factory.deinit()`.) + factory.deinit(); + _ = g_ops.fetchAdd(1, .monotonic); + } +} + +fn runReentrant(io: std.Io, cfg: Config) !void { + const threads = try g_alloc.alloc(std.Thread, cfg.threads); + defer g_alloc.free(threads); + for (threads) |*t| t.* = try std.Thread.spawn(.{}, reentrantThread, .{ io, cfg }); + for (threads) |t| t.join(); +} diff --git a/stress-tests/zig/lifecycle_churn/run.py b/stress-tests/zig/lifecycle_churn/run.py new file mode 100755 index 00000000..b946515a --- /dev/null +++ b/stress-tests/zig/lifecycle_churn/run.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Build + run one lifecycle_churn scenario. + +churn_stress is a single self-contained binary (DebugAllocator as the +allocator, so a leak/UAF fails at exit). This wrapper just builds it, +runs the requested --scenario with a wall-clock ceiling, and checks for a +`SUMMARY: OK` line and the absence of crash markers. `--tsan` rebuilds +zzdds + the binary with ThreadSanitizer first (the CI examples-tsan-style +lane). + +Pass/fail: exit 0, one `SUMMARY: OK` line, no `FAIL:` / panic / sanitizer +report. +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +APP_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(APP_DIR.parents[2] / "examples")) +import _common # noqa: E402 + +SUMMARY_RE = re.compile(r"^SUMMARY: OK scenario=(\w+) ops=(\d+)", re.M) +BAD = ("FAIL:", "panic", "Segmentation fault", "General protection", + "ThreadSanitizer", "data race", "leaked") + + +def build(tsan: bool) -> Path: + log = _common.mktemp_logdir("churn-stress") / "build.log" + cmd = ["zig", "build"] + if tsan: + cmd.append("-Dsanitize-thread=true") + if not _common.run_build(cmd, cwd=APP_DIR, log_path=log, timeout=900): + print(Path(log).read_text(errors="replace"), file=sys.stderr) + raise SystemExit("FAIL: churn_stress build failed") + return APP_DIR / "zig-out" / "bin" / "churn_stress" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--scenario", required=True, choices=["entities", "reentrant"]) + ap.add_argument("--threads", type=int, default=6) + ap.add_argument("--iterations", type=int, default=40) + ap.add_argument("--duration", type=int, default=8) + ap.add_argument("--domain", type=int, default=71) + ap.add_argument("--seed", type=int, default=1) + ap.add_argument("--timeout", type=int, default=90) + ap.add_argument("--tsan", action="store_true") + args = ap.parse_args() + + binary = build(args.tsan) + cmd = [str(binary), "--scenario", args.scenario, + "--threads", str(args.threads), "--iterations", str(args.iterations), + "--duration", str(args.duration), "--domain", str(args.domain), + "--seed", str(args.seed)] + logdir = _common.mktemp_logdir("churn-stress-run") + lp = _common.LiveProcess(cmd, log_path=logdir / f"{args.scenario}.log") + rc = lp.stop(grace=10) if lp.wait(timeout=args.timeout) is None else lp.proc.returncode + text = lp.log_text() + + problems = [] + if rc != 0: + problems.append(f"exit {rc}") + if not SUMMARY_RE.search(text): + problems.append("no `SUMMARY: OK` line") + for b in BAD: + if b in text: + problems.append(f"log contains {b!r}") + break + + if problems: + print(f"FAIL: {args.scenario}: {', '.join(problems)}", file=sys.stderr) + print(f"Log: {logdir}/{args.scenario}.log", file=sys.stderr) + return 1 + print(f"OK: churn_stress --scenario {args.scenario} (threads {args.threads})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())