Skip to content

Commit bde1cdc

Browse files
committed
REVIEW REMEDIATE: address Phase 14 findings (13 A's + 2 B's)
22 findings classified 13A/2B/0C/7D from the Phase 14 review pass; all actionable items folded into one commit. 7 D's preserved in review.md \"Action Taken\" column with rationale. Production code: - src/NexNet.Testing/Recording/MethodIdMap.cs - doc-comment now points at the post-rename regression test (#1) Test-only nexus (src/NexNet.Testing.Tests/EditorAppNexus.cs): - ListActiveEditors Task.Delay now guarded on CanBeCanceled - happy-path callers passing CancellationToken.None no longer pay the 200ms tax (#5) - OnAuthorize override comment now documents AND-semantics, marker-only behavior, and per-app policy choice rationale (#17) - ResetAll() XML comment explains why PingCount is omitted (instance-scoped) and what enabling Parallelizable would race (#10, #21) - Removed redundant 'using NexNet;' (#20) Test files: - AssertionTests + NexusTestHostTests gained [SetUp] ResetAll() for fixture-convention consistency (#9) - EditorAppShowcaseTests: - CT-propagation test loosened to Throws.InstanceOf<OperationCanceledException> so the test pins the semantic, not the concrete subtype (#7) - WaitFor_DraftSaved_ResolvesWhenInvoked - producer now runs in Task.Run with 100ms delay so the waiter actually exercises the async-arrival path (#2) - MixedTraffic - pipe + channel now 'await using' for failure-path safety; assertions tightened with times: 1; added AssertNotReceived on alice + carol for WhisperReceived (#3, #13) - StreamEdits_AllOpsCollected - channel now 'await using' (#14) - New: OpenDocument_NotifiesOthersExceptCaller - pins the EditorJoined GroupExceptCaller broadcast that fires inside OpenDocument (#4) - New: Connect_WithoutIdentity_IsRejectedAtHandshake - pins the discovery that anonymous sessions are rejected at handshake by the framework (TransportException Authentication failed), not by OnAuthorize (#18) - Stale '50ms' comment updated to reflect the 200ms delay + CanBeCanceled guard (#6) 82/82 NexNet.Testing.Tests pass (+2 new tests vs pre-remediation 80).
1 parent 28921e3 commit bde1cdc

7 files changed

Lines changed: 212 additions & 95 deletions

File tree

_sessions/add-nexnet-testing/review.md

Lines changed: 104 additions & 70 deletions
Large diffs are not rendered by default.

_sessions/add-nexnet-testing/workflow.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ remote: https://github.com/Dtronix/NexNet.git
66
base-branch: master
77

88
## State
9-
phase: REVIEW
9+
phase: REMEDIATE
1010
status: active
1111
issue: discussion
1212
pr: 77
@@ -197,3 +197,5 @@ Quiescence is the load-bearing primitive that makes negative assertions (`Assert
197197
| 7 | 2026-05-27 IMPLEMENT | 2026-05-27 IMPLEMENT | Phase 14c complete: replaced HarnessShowcaseTests.cs with EditorAppShowcaseTests.cs (10 tests: SaveDraft broadcast + identity, LeaveDocument GroupExceptCaller, Whisper Client-by-id, BroadcastSystemAnnouncement AsNonAdmin throws / AsAdmin all-broadcast, SaveDraft AsReader throws, ListActiveEditors returns names, ListActiveEditors cancelled-token throws, UploadAttachment byte streaming, StreamEdits channel typed streaming) plus migrated Groups_EmptyGroup_HasNoMembers. **Behavior discovery:** the framework does NOT short-circuit pre-cancelled tokens — the cancel signal is only sent when the client-side CT FIRES during the call. ListActiveEditors gained an `await Task.Delay(200, ct)` so client-side CT cancellation has time to propagate to the server-side CT. 73/73 tests pass. |
198198
| 7 | 2026-05-27 IMPLEMENT | 2026-05-27 IMPLEMENT | Phase 14d complete: added 7 more showcase tests (test 15 Groups_EmptyGroup_HasNoMembers was migrated in 14c). New: WaitFor_DraftSaved_ResolvesWhenInvoked, WaitFor_Timeout_Throws, MixedTraffic_QuiesceAsync_WaitsForEverything (4-shape concurrent traffic — SaveDraft + Whisper + UploadAttachment + StreamEdits across 3 clients), Groups_Introspection_ReflectsLiveMembership (open + leave + reopen), ServerSide_AssertReceived_SaveDraft, AssertReceived_TimesMismatch_DiagnosticIncludesArgs (showcase shape of the AssertionTests version), ArgMatchers_AnyAndPredicate (Arg.Any + Arg.Is). 80/80 tests pass. |
199199
| 7 | 2026-05-27 IMPLEMENT | 2026-05-27 IMPLEMENT | Phase 14e complete: pr-body.md sample rewritten from the old JoinGroup/BroadcastToGroup snippet to the new editor-app shape (test #1: OpenDocument + SaveDraft + DraftSaved callback observation across 3 identities). Added a Phase 14 entry to the "Plan items implemented" section; bumped test count from 65 → 80; revised summary to mention the demo rewrite. PR body pushed to GitHub (PR #77). No NexNet.Testing README exists and the main README + docs articles do not mention the testing harness, so nothing else to update. Phase 14 (all 5 sub-phases) complete. Transitioning to REVIEW. |
200+
| 7 | 2026-05-27 REVIEW | 2026-05-27 REMEDIATE | REVIEW analysis pass on the Phase 14 diff (commits aaa67c7..28921e3) via general-purpose agent. 22 distinct findings after consolidating 5 overlap pairs. Recs: 13A / 2B / 0C / 7D. User accepted all as recommended. Classifications + per-finding "Action Taken" in `review.md`. **Behavior discovery during finding #18:** anonymous (null-identity) sessions are rejected at the handshake by the framework (NexusSession.Receiving returns DisconnectReason.Authentication when Authenticate returns null), so the OnAuthorize `is not TestIdentity` null-identity guard is dead code via this harness's auth path. Documented in test body + Action Taken. |
201+
| 7 | 2026-05-27 REMEDIATE | 2026-05-27 REMEDIATE | All 13 A's + 2 B's addressed in one commit. Key fixes: ListActiveEditors Task.Delay now guarded on `CanBeCanceled` (test #5 — happy-path callers no longer pay 200ms tax); OnAuthorize AND-semantics + marker-only behavior documented inline (#17); ResetAll comment now explains PingCount omission and the sequential-fixture-execution requirement (#10, #21); `[SetUp]` ResetAll() added to AssertionTests + NexusTestHostTests (#9); CT-propagation test loosened to `Throws.InstanceOf<OperationCanceledException>` (#7); MixedTraffic gained pipe/channel `await using` + `times: 1` + negative WhisperReceived assertions (#3, #13, #14); WaitFor producer now runs in background with 100ms delay (#2); new tests OpenDocument_NotifiesOthersExceptCaller (#4) and Connect_WithoutIdentity_IsRejectedAtHandshake (#18). MethodIdMap doc-comment fixed (#1); stale "50ms" comment updated (#6); `using NexNet;` removed (#20). 82/82 NexNet.Testing.Tests pass (+2 new). |

src/NexNet.Testing.Tests/AssertionTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ namespace NexNet.Testing.Tests;
77

88
internal class AssertionTests
99
{
10+
[SetUp]
11+
public void ResetEditorState() => EditorServerNexus.ResetAll();
12+
1013
private async Task<(NexusTestHost<EditorServerNexus, EditorServerNexus.ClientProxy, EditorClientNexus, EditorClientNexus.ServerProxy> host,
1114
NexusTestClient<EditorClientNexus, EditorClientNexus.ServerProxy> client,
1215
EditorServerNexus server)>

src/NexNet.Testing.Tests/EditorAppNexus.cs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using System.Threading;
77
using System.Threading.Tasks;
88
using MemoryPack;
9-
using NexNet;
109
using NexNet.Invocation;
1110
using NexNet.Pipes;
1211

@@ -39,6 +38,13 @@ internal partial class EditorServerNexus : ServerNexusBase<EditorServerNexus.Cli
3938
public static byte[]? LastUploadedBytes;
4039
public static List<string>? LastCollectedItems;
4140

41+
// Resets cross-fixture process-global state. PingCount is intentionally NOT touched —
42+
// it is per-instance (each test gets its own EditorServerNexus via the factory) and
43+
// doesn't bleed across tests. The Documents / ActiveEditors / LastUploadedBytes /
44+
// LastCollectedItems statics persist across nexus instances and MUST be cleared by
45+
// every test fixture that exercises these fields, regardless of whether the test
46+
// reads them directly. Tests assume sequential fixture execution; enabling
47+
// [assembly: Parallelizable(ParallelScope.Fixtures)] would race this state.
4248
public static void ResetAll()
4349
{
4450
Documents.Clear();
@@ -134,12 +140,14 @@ public async ValueTask BroadcastSystemAnnouncement(string message)
134140

135141
public async ValueTask<string[]> ListActiveEditors(string docId, CancellationToken cancellationToken)
136142
{
137-
// Observable async point so callers passing a CancellationToken can actually observe
138-
// cancellation. The framework only sends a cancel signal when the client-side CT
139-
// FIRES during the call (pre-cancelled tokens are not short-circuited by the proxy),
140-
// so the server needs an awaiting point long enough for that signal to arrive. 200ms
141-
// gives ample headroom for in-process propagation.
142-
await Task.Delay(200, cancellationToken);
143+
// Observable async point so callers passing a cancellable CancellationToken can
144+
// actually observe cancellation. The framework only sends a cancel signal when the
145+
// client-side CT FIRES during the call (pre-cancelled tokens are not short-circuited
146+
// by the proxy), so the server needs an awaiting point long enough for that signal
147+
// to arrive. Guarded on CanBeCanceled so happy-path callers (CancellationToken.None)
148+
// don't pay the latency tax.
149+
if (cancellationToken.CanBeCanceled)
150+
await Task.Delay(200, cancellationToken);
143151
if (!ActiveEditors.TryGetValue(docId, out var registry))
144152
return Array.Empty<string>();
145153
return registry.Values.ToArray();
@@ -168,6 +176,13 @@ public async ValueTask StreamEdits(string docId, INexusDuplexChannel<EditOp> cha
168176
doc.Edits.Enqueue(op);
169177
}
170178

179+
// Policy: AND-semantics over requiredPermissions — every declared permission must be
180+
// present on the identity, otherwise Unauthorized. This is a per-app choice, not a
181+
// framework default — the framework hands you the int[] of required permissions and
182+
// OnAuthorize decides how to interpret them. An OR-semantics policy would just flip the
183+
// loop to "any match → Allowed". Marker-only [NexusAuthorize<TPermission>()] (empty
184+
// requiredPermissions) is currently treated as "TestIdentity sufficient" since the for
185+
// loop is skipped entirely.
171186
protected override ValueTask<AuthorizeResult> OnAuthorize(
172187
ServerSessionContext<EditorServerNexus.ClientProxy> context,
173188
int methodId,

src/NexNet.Testing.Tests/EditorAppShowcaseTests.cs

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,21 @@ public async Task ListActiveEditors_ReturnsNames()
151151
[Test]
152152
public async Task ListActiveEditors_CancelledToken_PropagatesAndThrows()
153153
{
154-
// Client-side CT fires partway through the server's `await Task.Delay(50, ct)` in
155-
// ListActiveEditors. Framework propagates the cancel signal to the server-side CT,
154+
// Client-side CT fires partway through the server's `await Task.Delay(200, ct)` in
155+
// ListActiveEditors (the delay is guarded on CanBeCanceled, so only cancellable
156+
// callers pay it). Framework propagates the cancel signal to the server-side CT,
156157
// which causes the delay to throw. Pre-cancelled tokens are not short-circuited by
157-
// the proxy — the cancel signal is only sent when the client-side CT FIRES.
158+
// the proxy — the cancel signal is only sent when the client-side CT FIRES. The
159+
// assertion uses OperationCanceledException (the base) rather than TaskCanceledException
160+
// so the test pins the semantic, not the concrete subtype.
158161
await using var host = await CreateHost();
159162
var alice = await host.ConnectAsAsync(TestIdentity.Of("alice"));
160163

161164
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(10));
162165

163-
Assert.ThrowsAsync<TaskCanceledException>(
164-
async () => await alice.Server.ListActiveEditors("design.md", cts.Token));
166+
Assert.That(
167+
async () => await alice.Server.ListActiveEditors("design.md", cts.Token),
168+
Throws.InstanceOf<OperationCanceledException>());
165169
}
166170

167171
[Test]
@@ -193,7 +197,7 @@ public async Task StreamEdits_AllOpsCollected()
193197
.Select(i => new EditOp(i, $"text-{i}"))
194198
.ToArray();
195199

196-
var channel = alice.Nexus.Context.CreateChannel<EditOp>();
200+
await using var channel = alice.Nexus.Context.CreateChannel<EditOp>();
197201
var serverCall = alice.Server.StreamEdits("design.md", channel).AsTask();
198202
var writer = await channel.GetWriterAsync();
199203
foreach (var op in ops)
@@ -215,11 +219,19 @@ public async Task WaitFor_DraftSaved_ResolvesWhenInvoked()
215219

216220
await alice.Server.OpenDocument("design.md");
217221
await bob.Server.OpenDocument("design.md");
222+
await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2));
218223

224+
// Producer runs in the background AFTER a small delay so the waiter actually
225+
// observes the async-arrival path, not the "already recorded" fast path.
219226
var waiter = alice.WaitFor<IEditorClientNexus>(
220227
n => n.DraftSaved("bob", "v1"), TimeSpan.FromSeconds(3));
221-
await bob.Server.SaveDraft("design.md", "v1");
228+
var producer = Task.Run(async () =>
229+
{
230+
await Task.Delay(100);
231+
await bob.Server.SaveDraft("design.md", "v1");
232+
});
222233
await waiter;
234+
await producer;
223235
}
224236

225237
[Test]
@@ -252,14 +264,15 @@ public async Task MixedTraffic_QuiesceAsync_WaitsForEverything()
252264
var ops = Enumerable.Range(0, 5).Select(i => new EditOp(i, $"o{i}")).ToArray();
253265

254266
// Fire all four traffic shapes in flight at once.
267+
await using var pipe = alice.CreatePipe();
268+
await using var channel = bob.Nexus.Context.CreateChannel<EditOp>();
269+
255270
var draftCall = alice.Server.SaveDraft("design.md", "draft-a").AsTask();
256271
var whisperCall = carol.Server.Whisper(bob.Nexus.Context.Id, "ping").AsTask();
257272

258-
var pipe = alice.CreatePipe();
259273
var uploadCall = alice.Server.UploadAttachment("design.md", pipe).AsTask();
260274
var uploadDrive = pipe.PipeUploadAsync(attachment).AsTask();
261275

262-
var channel = bob.Nexus.Context.CreateChannel<EditOp>();
263276
var streamCall = bob.Server.StreamEdits("design.md", channel).AsTask();
264277
var streamDrive = Task.Run(async () =>
265278
{
@@ -270,18 +283,65 @@ public async Task MixedTraffic_QuiesceAsync_WaitsForEverything()
270283

271284
await Task.WhenAll(draftCall, whisperCall, uploadDrive, uploadCall, streamDrive, streamCall)
272285
.WaitAsync(TimeSpan.FromSeconds(5));
273-
await pipe.DisposeAsync();
274286
await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(5));
275287

276-
// Every callback delivered, every server-side append visible — no extra waits needed.
277-
alice.AssertReceived<IEditorClientNexus>(n => n.DraftSaved("alice", "draft-a"));
278-
bob.AssertReceived<IEditorClientNexus>(n => n.DraftSaved("alice", "draft-a"));
279-
carol.AssertReceived<IEditorClientNexus>(n => n.DraftSaved("alice", "draft-a"));
280-
bob.AssertReceived<IEditorClientNexus>(n => n.WhisperReceived("carol", "ping"));
288+
// Every callback delivered exactly once on the expected recipients; non-recipients
289+
// never see the per-target whisper.
290+
alice.AssertReceived<IEditorClientNexus>(n => n.DraftSaved("alice", "draft-a"), times: 1);
291+
bob.AssertReceived<IEditorClientNexus>(n => n.DraftSaved("alice", "draft-a"), times: 1);
292+
carol.AssertReceived<IEditorClientNexus>(n => n.DraftSaved("alice", "draft-a"), times: 1);
293+
bob.AssertReceived<IEditorClientNexus>(n => n.WhisperReceived("carol", "ping"), times: 1);
294+
alice.AssertNotReceived<IEditorClientNexus>(
295+
n => n.WhisperReceived(Arg.Any<string>(), Arg.Any<string>()));
296+
carol.AssertNotReceived<IEditorClientNexus>(
297+
n => n.WhisperReceived(Arg.Any<string>(), Arg.Any<string>()));
281298
Assert.That(EditorServerNexus.Documents["design.md"].Attachment, Is.EqualTo(attachment));
282299
Assert.That(EditorServerNexus.Documents["design.md"].Edits.ToArray(), Is.EqualTo(ops));
283300
}
284301

302+
[Test]
303+
public async Task OpenDocument_NotifiesOthersExceptCaller()
304+
{
305+
// Pairs with LeaveDocument_NotifiesOthersExceptCaller — exercises the EditorJoined
306+
// GroupExceptCaller broadcast that fires inside OpenDocument.
307+
await using var host = await CreateHost();
308+
var alice = await host.ConnectAsAsync(TestIdentity.Of("alice"));
309+
var bob = await host.ConnectAsAsync(TestIdentity.Of("bob"));
310+
var carol = await host.ConnectAsAsync(TestIdentity.Of("carol"));
311+
312+
await alice.Server.OpenDocument("design.md");
313+
await bob.Server.OpenDocument("design.md");
314+
await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2));
315+
316+
// Carol joins last → alice + bob receive EditorJoined("carol"); carol does not see
317+
// her own join. Alice never sees her own join either (first joiner, group empty at
318+
// broadcast time).
319+
await carol.Server.OpenDocument("design.md");
320+
await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2));
321+
322+
alice.AssertReceived<IEditorClientNexus>(n => n.EditorJoined("carol"), times: 1);
323+
bob.AssertReceived<IEditorClientNexus>(n => n.EditorJoined("carol"), times: 1);
324+
carol.AssertNotReceived<IEditorClientNexus>(n => n.EditorJoined("carol"));
325+
alice.AssertNotReceived<IEditorClientNexus>(n => n.EditorJoined("alice"));
326+
}
327+
328+
[Test]
329+
public async Task Connect_WithoutIdentity_IsRejectedAtHandshake()
330+
{
331+
// The anonymous-call-to-gated-method path can't be exercised directly because the
332+
// framework rejects null-identity sessions at the handshake (NexusSession.Receiving
333+
// returns DisconnectReason.Authentication when Authenticate returns null). The
334+
// TestAuthenticationStore-backed override returns null for a missing token, so
335+
// ConnectAsync() (no identity) never completes a usable session and the OnAuthorize
336+
// override's `is not TestIdentity` guard isn't reachable via this harness's auth path.
337+
await using var host = await CreateHost();
338+
339+
Assert.That(
340+
async () => await host.ConnectAsync(),
341+
Throws.InstanceOf<NexNet.Transports.TransportException>()
342+
.With.Message.Contains("Authentication"));
343+
}
344+
285345
[Test]
286346
public async Task Groups_Introspection_ReflectsLiveMembership()
287347
{

src/NexNet.Testing.Tests/NexusTestHostTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ namespace NexNet.Testing.Tests;
77

88
internal class NexusTestHostTests
99
{
10+
[SetUp]
11+
public void ResetEditorState() => EditorServerNexus.ResetAll();
12+
1013
[Test]
1114
public async Task EndToEnd_InvokeServerMethodOverInProcess()
1215
{

src/NexNet.Testing/Recording/MethodIdMap.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ namespace NexNet.Testing.Recording;
1818
/// AOT/trimming caveat: metadata-token order remains stable under standard runtimes today; if a
1919
/// future host re-orders <c>GetMethods()</c> output, assertions will surface a clear "method not
2020
/// recorded" failure rather than silently match the wrong method. See
21-
/// <c>MethodIdMapTests.GeneratorParity_DemoInterfaces</c> for a regression test that locks in the
22-
/// expected layout.
21+
/// <c>MethodIdMapTests.GeneratorParity_EditorServerInterface_AssignsExpectedIds</c> (and the
22+
/// matching client-interface test) for the regression tests that lock in the expected layout.
2323
/// </remarks>
2424
internal static class MethodIdMap
2525
{

0 commit comments

Comments
 (0)