Repository navigation
Make TrackedSession bookkeeping thread-safe - #3070
jeremydmiller merged 1 commit into
Conversation
EnvelopeHistory backed its records with an unsynchronized List<T>, but with IncludeExternalTransports() records arrive concurrently from parallel transport listener threads (e.g. multiple Rabbit MQ queues). Concurrent Add + LINQ iteration intermittently threw NullReferenceException from RecordCrossApplication, surfacing through AssertNoExceptionsWereThrown and failing whatever test had an active tracked session. TrackedSession's _statuses and _exceptions lists had the same unguarded add/enumerate pattern. All EnvelopeHistory members now synchronize on a private lock (Records returns a snapshot), and the session lists are guarded by locking with snapshot reads. Includes a regression stress test that reproduces the NullReferenceException deterministically against the previous implementation.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR hardens Wolverine tracking against concurrency issues when multiple transport listener threads record activity for the same envelope/session.
Changes:
- Add synchronization around
TrackedSessionstatus/exception collections via snapshot reads + locked writes - Make
EnvelopeHistorythread-safe by guarding_recordswith a private lock and returning record snapshots - Add a stress test reproducing concurrent listener recording
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| src/Wolverine/Tracking/TrackedSession.cs | Avoid concurrent enumeration/modification by snapshotting and locking around exception/status collections |
| src/Wolverine/Tracking/EnvelopeHistory.cs | Serialize access to _records via a private lock; return snapshots to prevent concurrent iteration faults |
| src/Testing/CoreTests/Tracking/envelope_history_thread_safety.cs | Add a multi-writer stress test to validate thread safety |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private EnvelopeRecord[] statusesSnapshot() | ||
| { | ||
| lock (_statuses) | ||
| { | ||
| return _statuses.ToArray(); | ||
| } | ||
| } | ||
|
|
||
| private Exception[] exceptionsSnapshot() | ||
| { | ||
| lock (_exceptions) | ||
| { | ||
| return _exceptions.ToArray(); | ||
| } | ||
| } |
There was a problem hiding this comment.
The camelCase is on purpose — the private methods in these files already use it
(lastOf, markLastCompleted, writeGrid, ...), so I just matched the surrounding code.
Can rename if the maintainers prefer PascalCase here.
There was a problem hiding this comment.
Copilot is lying. Our naming convention is camelCasing for private members:)
| lock (_statuses) | ||
| { | ||
| return _statuses.ToArray(); | ||
| } |
There was a problem hiding this comment.
Locking on the collections themselves is safe here — they're private and never leave the
class. But I have no strong feelings about it, happy to add dedicated lock fields if that's
the style maintainers prefer.
| var conditions = $"Conditions:\n{_conditions.Select(x => x.ToString())!.Join("\n")}"; | ||
| var activity = $"Activity:\n{AllRecordsInOrder().Select(x => x.ToString()).Join("\n")}"; | ||
| var exceptions = $"Exceptions:\n{_exceptions.Select(x => x.ToString()).Join("\n")}"; | ||
| var exceptions = $"Exceptions:\n{exceptionsSnapshot().Select(x => x.ToString()).Join("\n")}"; | ||
|
|
||
| return $"{conditions}\n\n{activity}\\{exceptions}"; |
There was a problem hiding this comment.
Nice catch, but this PR only changed the line above it. Looks like it was meant to be \n\n.
I think it is out of scope for this PR, but if you want, I am happy to fix it.
| public IEnumerable<EnvelopeRecord> Records | ||
| { | ||
| get | ||
| { | ||
| lock (_lock) | ||
| { | ||
| return _records.ToArray(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Agreed, the signature could say this better. I can change it to EnvelopeRecord[] or IReadOnlyList<EnvelopeRecord> or leave it as is — whichever @jeremydmiller prefers.
…3070) (#4585) EnvelopeHistory kept its EnvelopeRecords in a bare List<T> with no synchronisation while records arrive concurrently. A resize racing a read threw out of IsComplete(), surfacing as an AggregateException from TrackedSession.AssertNoExceptionsWereThrown() with no hint of the real cause. #3069 reported it through RecordCrossApplication with RabbitMQ. #3968 confirmed the same race independently on the purely LOCAL path with no external transport, reached through WolverineRuntime.MessageSucceeded -- so it races on handler-completion threads too, not only transport-listener threads. Their trigger was MultipleHandlerBehavior.Separated: one ExecuteAndWaitAsync fanning out to many envelopes completing at once. Reproduced roughly 1 run in 7 of a ~1850-test suite, ~3% over a longer history. The reporter verified by reflection that no shipped 5.x has the fix: 5.39.3 and 5.40.1 both lack the _lock field, and 5.40.1 published ~8 weeks AFTER 6.8.0 carried it. So the only route for a 5.x user was a major-version upgrade to fix test-harness flakiness. The locking topology matches 6.x member for member -- verified mechanically, not by eye: - the two writers, RecordLocally and RecordCrossApplication, hold the lock across their whole body - IsComplete, Has, MessageFor, Message and Records take it for the read - the three private helpers (lastOf, both markLastCompleted overloads) stay unlocked, because they are only ever called from inside an already-locked writer Records also now hands out a snapshot rather than the live list. On 5.x it was `public IEnumerable<EnvelopeRecord> Records => _records;`, which lets a caller enumerate the list while a recording thread is adding to it -- the same race one level removed. This is test-support code (Wolverine.Tracking is not in a production path), no public API changes. ## On the tests Three, driven hard and directly: a 3%-of-full-suite race is no use as a regression test. All three fail on the unfixed class on EVERY run -- checked three times over, with the baseline's own build asserted clean first so it could not be a stale binary. Worth recording why the first one needed strengthening. As first written it exercised IsComplete(), Has() and Records.Count(), and it PASSED unfixed: IsComplete and Has walk the list with an indexed for loop, and List<T> answers Count without enumerating, so none of the three throw under concurrent mutation -- they can read a torn value, but quietly. Only LINQ over the list raises "Collection was modified", so the test now drives Message and MessageFor, which enumerate. The indexed readers are kept for coverage but are not what makes the test fail. Full CoreTests on 5.0: 1695 green. Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What & why
When tracked sessions run with
IncludeExternalTransports(), envelope records arrive concurrently from parallel transport listener threads (in our case ~90 Rabbit MQ queues on one host).EnvelopeHistorybacked its records with an unsynchronizedList<T>, so concurrentAdd+ LINQ iteration intermittently threw aNullReferenceExceptionfrom inside the tracking bookkeeping itself, surfacing throughAssertNoExceptionsWereThrown()and failing whatever test happened to have an active session (~1 in 4 runs of a ~50-test integration suite for us):TrackedSession's_statusesand_exceptionslists have the same unguarded add/enumerate pattern (Record(...)/LogException(...)append from listener threads whileAllRecordsInOrder()/AssertNoExceptionsWereThrown()enumerate).The fix
EnvelopeHistory: all members that touch_recordsnow synchronize on a private lock; theRecordsproperty returns a snapshot array instead of the live list.TrackedSession:_statusesand_exceptionsare appended under a lock on the collection instance, and all read sites go through snapshot helpers.This is test-support code on cold paths, so the locking has no practical overhead.
Testing
envelope_history_thread_safetyhammersRecordCrossApplication+ the read paths from 8 threads. Against the previous implementation it fails deterministically (in under 150 ms) with the exactNullReferenceExceptionfrom the report above; with this change it passes.CoreTests.Trackingtests pass on net9.0 and net10.0.Fixes #3069