diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4283ca6e..865e5b08 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' @@ -25,7 +25,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y libmsquic - name: Install .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.*' source-url: https://api.nuget.org/v3/index.json @@ -38,12 +38,20 @@ jobs: - name: Validate AOT compatibility run: dotnet build src/Samples/NexNetSample.Aot.Client -c Release --no-restore -p:TreatWarningsAsErrors=true -p:NoWarn=IL2104%3BIL3053%3BIL3050 + # Test runs use --logger "console;verbosity=minimal" so individual passing tests are + # suppressed (the integration suite alone would dump thousands of lines per CI run). + # Each assembly still emits a single summary line ("Passed! - Failed: 0, Passed: N, + # Total: N, Duration: X") and full failure details when tests fail. Step elapsed time in + # the GitHub Actions UI shows progress. - name: Execute integration tests - run: dotnet test src/NexNet.IntegrationTests -c Release --no-build -v=normal + run: dotnet test src/NexNet.IntegrationTests -c Release --no-build -v=minimal --logger "console;verbosity=minimal" - name: Execute generator tests - run: dotnet test src/NexNet.Generator.Tests -c Release --no-build -v=normal - + run: dotnet test src/NexNet.Generator.Tests -c Release --no-build -v=minimal --logger "console;verbosity=minimal" + + - name: Execute testing-harness tests + run: dotnet test src/NexNet.Testing.Tests -c Release --no-build -v=minimal --logger "console;verbosity=minimal" + - name: Pack NexNet run: dotnet pack src/NexNet -c Release -o ./artifacts --maxcpucount:1 @@ -52,12 +60,15 @@ jobs: - name: Pack NexNet.Asp run: dotnet pack src/NexNet.Asp -c Release -o ./artifacts --maxcpucount:1 - + + - name: Pack NexNet.Testing + run: dotnet pack src/NexNet.Testing -c Release -o ./artifacts --maxcpucount:1 + - name: Pack NexNet.Generator run: dotnet pack src/NexNet.Generator -c Release -o ./artifacts --maxcpucount:1 - name: Export artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: path: artifacts/* diff --git a/_sessions/add-nexnet-testing/_pr-body.md b/_sessions/add-nexnet-testing/_pr-body.md new file mode 100644 index 00000000..b8471e0a --- /dev/null +++ b/_sessions/add-nexnet-testing/_pr-body.md @@ -0,0 +1,124 @@ +## Summary +- Ships a new `NexNet.Testing` package: in-process transport, test host, recorders, server-side and per-client assertion APIs, streaming-helper extensions, and a quiescence primitive. +- Adds three minimal optional internal hooks to `NexNet` core (`IInvocationInterceptor`, `IPipeFactory`, `ServerConfig.OnAuthenticateOverride`) so the harness can instrument dispatch and auth without touching production hot paths. +- Demo + showcase nexus rewritten in Phase 14 from the original `JoinGroup`/`BroadcastToGroup` passthrough shape to a realistic `EditorServerNexus` document-editor domain that drives every harness feature through natural business verbs. +- Driven by 14 plan phases + 12 remediation phases (R1–R12) addressing 40 findings from a structured review. + +## Reason for Change + +End users building applications on NexNet need a way to test their nexus implementations — server methods, client callbacks, authorization rules, broadcasts, group routing, pipes, and channels — without standing up sockets, ports, TLS, or fake auth providers. Existing options either require real network plumbing or fork into custom test harnesses per project. + +## Impact + +New consumers of the package can write tests like: + +```csharp +await using var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>(); + +var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); +var bob = await host.ConnectAsAsync(TestIdentity.Of("bob", "Write")); +var carol = await host.ConnectAsAsync(TestIdentity.Of("carol", "Read")); + +await alice.Server.OpenDocument("design.md"); +await bob.Server.OpenDocument("design.md"); +await carol.Server.OpenDocument("recipe.txt"); + +await alice.Server.SaveDraft("design.md", "v1"); +await host.QuiesceAsync(); + +bob.AssertReceived(n => n.DraftSaved("alice", "v1")); +carol.AssertNotReceived( + n => n.DraftSaved(Arg.Any(), Arg.Any())); +Assert.That(host.Groups["doc-design.md"].Count, Is.EqualTo(2)); +``` + +The sample drives real business methods (`OpenDocument`, `SaveDraft`) instead of synthetic passthroughs — `SaveDraft` is `[NexusAuthorize(Write)]`-gated and broadcasts `DraftSaved` to `Context.Clients.Group($"doc-{docId}")` internally. Tests observe the resulting client callbacks plus the recorded server-side invocation. + +Existing NexNet consumers are unaffected: every new hook is `internal` (with InternalsVisibleTo for `NexNet.Testing`) and defaults to `null` so production sessions keep the unchanged dispatch path. + +## Plan items implemented as specified + +- **Phase 1** — `PendingInvocationCount` accessor on `ISessionInvocationStateManager`. +- **Phase 2** — `IInvocationInterceptor` interface + ConfigBase / NexusSessionConfigurations plumbing + Receiving wire-up. +- **Phase 3** — `IPipeFactory` interface + WrapLocal / WrapRemote hook points in `NexusPipeManager`. +- **Phase 4** — `ServerConfig.OnAuthenticateOverride` + `ServerNexusBase.Authenticate` consult-then-fallback. +- **Phase 5** — `NexNet.Testing` project with `InProcessTransport` (paired Pipes cross-wired), listener with Channel-based accept queue, `InProcessServerConfig`/`ClientConfig`, and `InProcessRendezvous`. +- **Phase 6** — `Type.InProcess` enum value + integration-test config branches (further expanded in R10). +- **Phase 7** — Recorder primitives (`InvocationRecorder`, `Arg.Any()`/`Arg.Is(predicate)` sentinels, `ArgMatcher`, `ExpressionParser`, `NexusAssertionException`). +- **Phase 8** — `TestInvocationInterceptor`, `QuiescenceCounters`, `QuiescenceTracker` with observe-zero/yield/re-observe pattern. +- **Phase 9** — `PipeRecording`, `TappingPipeReader`/`TappingPipeWriter`, `TappedNexusDuplexPipe`/`TappedRentedNexusDuplexPipe`, `TestPipeFactory`. +- **Phase 10** — `NexusTestHost.CreateAsync` static entry point, `NexusTestHost<...>`, `NexusTestClient<...>`, `TestIdentity.Of`, `TestAuthenticationStore`. +- **Phase 12** — Server-side `AssertReceived`/`AssertNotReceived`/`WaitFor` on the host with `MethodIdMap` + `ArgumentDeserializer`. +- **Phase 14** — Demo + showcase rewrite (added after first review pass). The old `DemoServerNexus` had passthrough methods (`JoinGroup`/`BroadcastToGroup`) that showed harness ergonomics in the worst light by forcing users to write passthrough plumbing just to test broadcasts. Replaced with a realistic `EditorServerNexus` document-editor domain (`OpenDocument`, `LeaveDocument`, `SaveDraft` [Write], `Whisper`, `BroadcastSystemAnnouncement` [Admin], `ListActiveEditors`, `UploadAttachment`, `StreamEdits`) that exercises every harness feature through natural business verbs. `OnAuthorize` override matches `TestIdentity.IsInRole` case-sensitive ordinal against `DocPermission` enum names. `EditorAppShowcaseTests.cs` adds 18 focused tests; existing dependent tests (`AssertionTests`, `ClientAssertionTests`, `NexusTestHostTests`, `StreamingExtensionsTests`, `MethodIdMapTests`) updated to the new domain; `HarnessSampleNexus.cs` + `HarnessShowcaseTests.cs` removed. + +## Deviations from plan implemented + +- **Hook reduction.** Plan called for three core factories (invocation interceptor + pipe factory + channel factory). Channels do not need a core factory in v1 — harness convenience helpers (`ChannelPublishAsync`/`ChannelCollectAsync`) own channel creation directly. The pipe-layer byte tap still observes any user-instantiated channels (Decisions §"Revisions after source verification"). +- **Hook location on `ConfigBase`.** Plan considered direct fields on `NexusSessionConfigurations`. Implementation stores them on `ConfigBase` (authoritative install point) and copies into the per-session struct at construction. Users install hooks once on the config and every session inherits them. +- **Transport home.** Plan started with `MemoryTransport` in NexNet core; revised to `InProcessTransport` shipped in `NexNet.Testing` so production code never has to take a test-package dependency. The integration-test-validation argument is preserved by `NexNet.IntegrationTests` taking a project reference to `NexNet.Testing` and exercising the InProcess transport through the existing `[TestCase]` matrix. +- **TimeProvider integration deferred** to follow-up issue #75. Verification revealed the actual scope (14 time references + 5 timers + 9 `Task.Delay` calls + an existing `TickCountOverride` test seam) was large enough to warrant its own focused workflow. The harness ships without time control as a known limitation; tests of time-sensitive logic (auth cache TTL, reconnect, ping) remain real-time-bound until #75 lands. + +## Gaps in original plan implemented + +These were uncovered during REVIEW after the 13-phase IMPLEMENT pass and addressed in the R1–R12 remediation: + +- **Quiescence counters were dead code.** Two of four counters (`bytesInTransit`, `pendingResults`) had no production wire-up at all — the unit tests called the mutators directly but real session activity never did. R1 fixed this with `CountingPipeWriter`/`CountingPipeReader` in `InProcessTransport` and per-session `InternalOnSessionSetup` callbacks on both server and client configs. +- **Multi-client `ConnectAsAsync` hang.** Phase 13 was reduced-scope because a second connect against the same host hung. R2 traced this to user-supplied factories returning a shared nexus instance corrupting `SessionContext`; the harness now detects this and throws a clear error instead of hanging. Multi-client tests (3 clients + group broadcast) added in R3. +- **Group introspection** (`host.Groups[name].Members`) added in R3 — previously deferred because of the multi-client hang. +- **Streaming-helper extensions** (`PipeUploadAsync`, `PipeDownloadAsync`, `ChannelPublishAsync`, `ChannelCollectAsync`) added in R4 (plan §11). +- **Per-client assertion API** on `NexusTestClient` added in R5 (plan §12). +- **`MethodIdMap` generator parity** — runtime build now uses `BindingFlags.DeclaredOnly` + alphabetically-sorted inherited interfaces (no `.Distinct()`), matching the generator's `OrderBy(i => i.ToDisplayString())` ordering. Pinned by new `MethodIdMapTests`. (R6) +- **Generator-aligned arg deserializer filter** — replaced namespace-prefix heuristic with exact-FullName matches against the generator's exclusion list. (R7) +- **Mismatch diagnostics include arg values** — `AssertReceived` failures now render `Notify("alpha"); Notify("beta")` instead of `#1, #1`. (R7) +- **Multi-arg + string-arg AssertionTests** added (R7), and the synthetic ExpressionParser test replaced with a real `n => n.IntValue` property-access case (R10). +- **Tap continuation deduplication** in `TappedNexusDuplexPipe` (R8); `WaitFor` switched to `Environment.TickCount64` monotonic clock (R8). +- **PredicateMatcher exception surfacing** — user-thrown predicate exceptions are now reported as `NexusAssertionException` with inner exception preserved, not silently masked. (R9) +- **Integration matrix expansion** — `[TestCase(Type.InProcess)]` added to all three hook test classes plus pipes, channels, collections, groups, cancellation, and invalid-invocations (+83 InProcess test cases, R10). +- **Polish**: `TestAuthenticationStore.OverrideDelegate` cached as a field; `NexusTestHost.DisposeAsync` logs teardown errors instead of swallowing; `InProcessRendezvous.Unregister` rewritten with `TryGetValue`+`ReferenceEquals`+`TryRemove`. (R11) +- **API ergonomics**: public `NexusTestHost.RecordedServerInvocationCount`; per-typeparam XML docs on `CreateAsync`; AppDomain/ALC scope documented on `InProcessRendezvous`. (R12) + +See `_sessions/add-nexnet-testing/review.md` for the full 40-finding classification table and per-finding remediation notes. + +## Migration Steps + +None for existing consumers. To adopt the harness: + +1. Add a project reference to `NexNet.Testing` from the test project. +2. Replace handcrafted socket setup with `await using var host = await NexusTestHost.CreateAsync<...>();`. +3. Use `client.Server`/`client.Nexus`/`client.AssertReceived(...)` and `host.AssertReceived(...)` for assertions. + +## Performance Considerations + +- Production hot paths take three nullability checks (`_invocationInterceptor`, `_pipeFactory`, `OnAuthenticateOverride`). All default to `null` in non-harness code; the JIT specializes the branch. +- `TappingPipeWriter.GetSpan` re-routes through `GetMemory` so the tap can read the source; cost is only paid on tapped (test-only) pipes. +- Quiescence counter increments are `Interlocked.*` ops on the shared host counter; one per byte movement / dispatch / pipe-open. Test-only overhead. + +## Security Considerations + +- All three new core hooks are `internal` with `[InternalsVisibleTo("NexNet.Testing")]`; promotion to public is a v1.x decision when external demand exists. +- The `NexNet.csproj` grant to `NexNet.Testing.Tests` was removed in R9 — the test project now reaches NexNet internals only through `NexNet.Testing`'s intended surface. +- `OnAuthenticateOverride` is server-side only; the harness installs it via the public-readable `InProcessServerConfig.OnAuthenticateOverride` property. Production code that doesn't set this property keeps the existing `OnAuthenticate`-only path. +- `TestAuthenticationStore` accumulates tokens for the host's lifetime by design (the store can't tell which tokens are still in use). Documented in XML; `Clear()` added for long-lived hosts. +- `ArgumentDeserializer` only ever runs on bytes the session has already validated — it doesn't ingest untrusted input. +- `ArgMatcher.PredicateMatcher` no longer swallows predicate-thrown exceptions silently; they surface as `NexusAssertionException` with inner-exception preserved. + +## Breaking Changes + +### Consumer-facing +- None for application code. +- **New public surface in `NexNet.Testing` is v1** — `NexusTestHost`, `NexusTestClient`, `NexusAssertionException`, `Arg.Any()`, `Arg.Is(...)`, `TestIdentity.Of(...)`, `PipeRecording`, `ChannelRecording`, `GroupView`, `GroupIntrospector`, `StreamingExtensions`. Future iteration may add overloads non-breakingly. + +### Internal +- `ConfigBase` gains `internal IInvocationInterceptor?` and `internal IPipeFactory?` properties. +- `ServerConfig` gains `internal Func?, ValueTask>? OnAuthenticateOverride`. +- `ISessionInvocationStateManager` gains `int PendingInvocationCount { get; }`. +- `NexusServer` gains `internal IServerSessionManager? SessionManagerInternal`. +- `NexNet.csproj` `InternalsVisibleTo` grants reshuffled — `NexNet.Testing` grant moved to source attribute; `NexNet.Testing.Tests` grant removed. + +## Test plan +- [ ] CI matrix passes (Generator + IntegrationTests + Testing tests). +- [ ] All hook tests run on both `Type.Tcp` and `Type.InProcess`. +- [ ] All pipe/channel/collection/group tests now exercise `Type.InProcess`. +- [ ] `NexNet.Testing.Tests` (80 tests) covers transport, recorder, MethodIdMap parity, quiescence under real load, host construction with multi-client connect and shared-instance detection, group introspection + broadcast, streaming helpers (upload/download/publish/collect), per-client assertions, and the 18-test `EditorAppShowcaseTests` covering identity flow, authorization (Write/Admin gates), `GroupExceptCaller`/`Client(id)`/`All` routing, pipe + channel streaming, mixed-traffic quiescence, and `Arg.Any`/`Arg.Is` matchers. diff --git a/_sessions/add-nexnet-testing/plan.md b/_sessions/add-nexnet-testing/plan.md new file mode 100644 index 00000000..9c0286a4 --- /dev/null +++ b/_sessions/add-nexnet-testing/plan.md @@ -0,0 +1,447 @@ +# Implementation Plan: add-nexnet-testing + +## Goal + +Ship `NexNet.Testing` — a new package providing an in-process transport (`InProcessTransport`), a test host (`NexusTestHost`), per-client handles with assertion APIs, recorders for invocations / pipes / channels, streaming convenience helpers, and a quiescence primitive — backed by two minimal optional internal hooks added to `NexNet` core. + +`TimeProvider` integration is **deferred to issue #75** and is out of scope for this workflow. + +## Key Concepts + +**Quiescence** is the load-bearing primitive that makes negative assertions (`AssertNotReceived`) deterministic. The harness tracks four counters and `QuiesceAsync()` returns when all are zero, with an `await Task.Yield()` re-check pass to drain synchronously-scheduled continuations. Counters: `bytesInTransit` (instrumented in `InProcessTransport`), `inDispatch` (incremented/decremented by `IInvocationInterceptor` around `nexus.InvokeMethod`), `pendingResults` (read from `SessionInvocationStateManager.PendingInvocationCount`), `activePipes` (tracked by the harness's `IPipeFactory` on rent / pipe-completion). + +**Outer-only interception.** `IInvocationInterceptor` wraps the call to `session._nexus.InvokeMethod(message)` in `NexusSession.Receiving.cs:682` (the `InvocationTask` static callback). It has access to the raw `InvocationMessage` (method ID + serialized arg bytes), not deserialized args. Generator code is not modified. For typed-argument assertion matching, the harness builds — at host startup — per-interface maps `(Type interface) → (ushort methodId) → (MethodInfo + Type>)` via reflection over `IServerNexus` / `IClientNexus`, then lazily MemoryPack-deserializes args from `InvocationMessage` only when an assertion needs to inspect them. + +**Pipe tap records consumed bytes, not visible bytes.** The harness's `IPipeFactory` produces a `TappingPipeReader` / `TappingPipeWriter` pair that wraps the pooled `NexusDuplexPipe`'s underlying readers/writers. The reader records on `AdvanceTo` (capturing only the slice the user committed past — matches "what the handler saw"). The writer records on `FlushAsync` (matches "what was actually sent over"). On pipe return-to-pool, the wrappers detach and the underlying pipe resets normally. + +**Channels are tapped by helpers, not by a core hook.** `INexusChannelReader` / `INexusChannelWriter` have no central creation point; the harness convenience methods (`ChannelCollect`, `ChannelPublish`, `TapChannel`) own creation themselves and return tap-wrapped instances. For user code that instantiates channels directly, the underlying pipe-layer byte tap is still observable. + +**Auth: dual mode.** `ServerConfig.OnAuthenticateOverride` is a new nullable `Func?, ValueTask>?`. When set, `ServerNexusBase.Authenticate` consults it before falling back to the user's `OnAuthenticate`. The harness installs an override that maps fake tokens (issued via `TestIdentity.Of(name, roles)`) to `IIdentity` instances. Users who want to test their *own* auth code leave the override unset. + +## Phase dependency graph + +``` +Phase 1 ────┐ + ├─→ Phase 8 (interceptor needs PendingInvocationCount) +Phase 2 ────┤ + ├─→ Phase 8 ─→ Phase 10 ─→ Phase 11 ─→ Phase 12 ─→ Phase 13 +Phase 3 ────┼─→ Phase 9 ─→ Phase 10 + │ +Phase 4 ────┴─→ Phase 10 +Phase 5 ──────────────────→ Phase 6 +Phase 5 ──────────────────────────→ Phase 10 +Phase 7 ────────────────────────────→ Phase 8 (recorder primitives feed interceptor) + Phase 12 (assertions consume recorder) +``` + +## Phase 1 — Add `PendingInvocationCount` accessor on `SessionInvocationStateManager` + +Trivial enabling change. `SessionInvocationStateManager._invocationStates` is a `ConcurrentDictionary`. Add an `internal int PendingInvocationCount => _invocationStates.Count;` so the harness can read it for the `pendingResults` quiescence counter. + +**Files:** `src/NexNet/Invocation/SessionInvocationStateManager.cs`. +**Tests:** No new tests; covered by Phase 8's quiescence tests later. Existing tests must pass unchanged. + +## Phase 2 — Add `IInvocationInterceptor` hook in core + +Define `internal interface IInvocationInterceptor` in `src/NexNet/Internals/IInvocationInterceptor.cs`: + +```csharp +internal interface IInvocationInterceptor +{ + ValueTask WrapAsync(InvocationMessage message, Func invoke); +} +``` + +Add `internal IInvocationInterceptor? InvocationInterceptor { get; init; }` to `ConfigBase`. Add a corresponding nullable field to the `NexusSessionConfigurations` readonly struct, populated from `Configs.InvocationInterceptor` at session-construction sites. Wire the hook in `NexusSession.Receiving.cs` `InvocationTask` (around line 682): + +```csharp +// before: await session._nexus.InvokeMethod(message).ConfigureAwait(false); +var interceptor = session._sessionConfigs.InvocationInterceptor; +if (interceptor is null) + await session._nexus.InvokeMethod(message).ConfigureAwait(false); +else + await interceptor.WrapAsync(message, + () => session._nexus.InvokeMethod(message)).ConfigureAwait(false); +``` + +Add `[assembly: InternalsVisibleTo("NexNet.Testing")]` to NexNet. + +**Files:** `src/NexNet/Internals/IInvocationInterceptor.cs` (new), `src/NexNet/Transports/ConfigBase.cs`, `src/NexNet/Internals/NexusSessionConfigurations.cs`, `src/NexNet/Internals/NexusSession.Receiving.cs`, `src/NexNet/NexNet.csproj` or `AssemblyInfo.cs` (InternalsVisibleTo), and the session construction sites that build the struct (find via grep on construction). + +**Tests:** Add a unit test in `NexNet.IntegrationTests` that confirms a no-interceptor case behaves identically (existing tests already cover this). Add one targeted test that installs a counting interceptor and verifies it observes every invocation. Ensure all 2628 existing integration tests pass. + +## Phase 3 — Add `IPipeFactory` hook in core + +Define `internal interface IPipeFactory` in `src/NexNet/Internals/IPipeFactory.cs`: + +```csharp +internal interface IPipeFactory +{ + INexusDuplexPipe WrapLocal(INexusDuplexPipe inner); + INexusDuplexPipe WrapRemote(INexusDuplexPipe inner); +} +``` + +Add `internal IPipeFactory? PipeFactory { get; init; }` to `ConfigBase`. Plumb through `NexusSessionConfigurations` as in Phase 2. Hook into `NexusPipeManager.RentPipe()` and `NexusPipeManager.RegisterPipe(byte otherId)` — after constructing the inner pipe, if `_session.SessionConfigs.PipeFactory` is non-null, wrap and return the wrapper instead. + +The wrapper must delegate every `INexusDuplexPipe` member to the inner pipe (including `Input`/`Output`, `ReadyTask`, `CompleteTask`, `CompleteAsync`, `Id`, `WriterCore`, `ReaderCore`). The harness's wrapper additionally exposes recording state. + +**Files:** `src/NexNet/Internals/IPipeFactory.cs` (new), `src/NexNet/Transports/ConfigBase.cs`, `src/NexNet/Internals/NexusSessionConfigurations.cs`, `src/NexNet/Pipes/NexusPipeManager.cs`. + +**Tests:** Add a unit test that installs a counting factory and verifies it wraps both local and remote pipe paths. Confirm all existing tests pass with null factory. + +## Phase 4 — Add `OnAuthenticateOverride` to `ServerConfig` + +Add `internal Func?, ValueTask>? OnAuthenticateOverride { get; init; }` to `ServerConfig`. Modify `ServerNexusBase.Authenticate(ReadOnlyMemory? token)` to consult it first: + +```csharp +internal async ValueTask Authenticate(ReadOnlyMemory? authenticationToken) +{ + var override = ((ServerConfig)Context.SessionContext.Configs).OnAuthenticateOverride; + if (override is not null) + return await override(authenticationToken).ConfigureAwait(false); + return await OnAuthenticate(authenticationToken).ConfigureAwait(false); +} +``` + +(Verify the precise navigation path from `ServerNexusBase` to its `ServerConfig` against actual source before committing.) + +**Files:** `src/NexNet/Transports/ServerConfig.cs`, `src/NexNet/Invocation/ServerNexusBase.cs`. + +**Tests:** Add a test that installs an override and confirms it is consulted; another that leaves it unset and confirms `OnAuthenticate` is called as before. Existing tests must pass. + +## Phase 5 — Create `NexNet.Testing` project + `InProcessTransport` + +Create `src/NexNet.Testing/NexNet.Testing.csproj` targeting `net10.0`, referencing `NexNet`. Configure as `true` for NuGet output. Mirror authoring metadata (description, license) from the existing `NexNet.Quic.csproj` so the package is publishable. + +Create the project structure: + +``` +src/NexNet.Testing/ +├── NexNet.Testing.csproj +├── Transports/InProcess/ +│ ├── InProcessTransport.cs : ITransport +│ ├── InProcessTransportListener.cs : ITransportListener +│ ├── InProcessServerConfig.cs : ServerConfig +│ ├── InProcessClientConfig.cs : ClientConfig +│ └── InProcessRendezvous.cs : process-local listener registry +``` + +`InProcessRendezvous` is a `static ConcurrentDictionary` keyed by endpoint string. `InProcessServerConfig.OnCreateServerListener()` registers a listener under `Endpoint`. `InProcessClientConfig.OnConnectTransport()` looks up the listener by `Endpoint` and asks it to produce a paired `InProcessTransport`. + +Each pair shares two `System.IO.Pipelines.Pipe` instances cross-wired: `serverOut → clientIn` and `clientOut → serverIn`. The two `InProcessTransport` instances each hold one input `PipeReader` and one output `PipeWriter`, exposed via `IDuplexPipe.Input` / `Output`. + +`CloseAsync(linger)` completes the writer; the peer's reader sees `IsCompleted = true` on next read, mirroring socket close semantics. + +Create `src/NexNet.Testing.Tests/NexNet.Testing.Tests.csproj` (NUnit) with one focused test class verifying the transport's basic round-trip, multi-message ordering, and clean close behavior in isolation. + +**Files:** all new under `src/NexNet.Testing/` and `src/NexNet.Testing.Tests/`. Update solution file `src/NexNet.sln` to include both new projects. + +**Tests:** New `NexNet.Testing.Tests` covers the transport in isolation. A `dotnet build src` confirms the solution builds end-to-end. + +## Phase 6 — Add `Type.InProcess` to `NexNet.IntegrationTests` matrix + +Reference `NexNet.Testing` from `NexNet.IntegrationTests`. Add `InProcess` to the `Type` enum in `BaseTests.cs`. Extend the config-creation switch to produce `InProcessServerConfig` / `InProcessClientConfig` with a unique endpoint string per test (e.g., test name + `Guid.NewGuid()`). Extend port-allocation paths to no-op for InProcess. + +Add `[TestCase(Type.InProcess)]` to a representative subset of existing test classes — start with `NexusClientTests`, `NexusServerTests`, `NexusServerTests_SendInvocation`, `NexusServerTests_ReceiveInvocation`, `NexusServerTests_NexusInvocations`, `NexusServerTests_Authorization`. Run and ensure all pass. If specific tests fail because they assume socket semantics (e.g., RemoteAddress format), adjust them to be transport-agnostic or skip them on InProcess with a clear annotation. + +After the representative subset is green, expand to the full matrix where it makes sense (skip Sockets/* and Security/RawTcpClient.cs which are inherently socket-bound). + +**Files:** `src/NexNet.IntegrationTests/NexNet.IntegrationTests.csproj`, `src/NexNet.IntegrationTests/BaseTests.cs`, plus per-test-class `[TestCase]` additions. + +**Tests:** No new tests authored; existing tests gain a new transport. All previously-passing tests must continue to pass on existing transports; InProcess matrix must be all-green. + +## Phase 7 — `NexusAssertionException`, `Arg` matchers, `InvocationRecorder`, expression machinery + +Create the recorder primitives in `NexNet.Testing` (no dependency on the interceptor yet): + +``` +src/NexNet.Testing/ +├── NexusAssertionException.cs +├── Recording/ +│ ├── Arg.cs (Any(), Is(predicate)) +│ ├── ArgMatcher.cs (internal — wildcard or equality) +│ ├── InvocationRecord.cs (methodId, raw args bytes, optional MethodInfo) +│ ├── InvocationRecorder.cs (thread-safe append, snapshot, lock-free reads) +│ └── ExpressionParser.cs (resolves Expression> to method + arg matchers) +``` + +`Arg.Any()` and `Arg.Is(predicate)` are sentinel calls intercepted by `ExpressionParser`. The parser walks the lambda body, identifies the `MethodCallExpression`, extracts the called `MethodInfo`, and converts each argument expression to an `ArgMatcher` (constant equality, wildcard, or predicate). + +**Files:** all new under `src/NexNet.Testing/`. + +**Tests:** Unit tests for `ExpressionParser` covering: simple constant args, `Arg.Any()`, `Arg.Is(predicate)`, mixed; methods with multiple parameters; unsupported expressions (throw with a clear message). + +## Phase 8 — `TestInvocationInterceptor` + `QuiescenceTracker` + lazy arg deserialization + +Implement the harness's `IInvocationInterceptor` impl in `NexNet.Testing`. On `WrapAsync`: + +1. Increment `inDispatch`. +2. Create an `InvocationRecord` populated with `(message.MethodId, message.Arguments)` (the raw args bytes). +3. Append to the per-session `InvocationRecorder`. +4. `try { await invoke(); } finally { Decrement(inDispatch); SignalChange(); }`. + +`QuiescenceTracker` aggregates counters across all sessions plus the in-process transport's `bytesInTransit` counter and the `IPipeFactory`'s `activePipes` counter (added in Phase 9). Exposes `Task QuiesceAsync(CancellationToken)` implementing the observe-zero / yield / re-observe-zero pattern from the design discussion, signaled by `AsyncManualResetEvent`. + +Lazy arg deserialization: at `NexusTestHost` construction, walk `typeof(IServerNexus)` and `typeof(IClientNexus)` via reflection. For each method, compute the same method ID the generator computes (lookup key: `MethodInfo`). The harness needs the `ValueTuple<...>` argument shape per method to deserialize via MemoryPack — derive from `MethodInfo.GetParameters()`. Store map: `(Type interfaceType, ushort methodId) → (MethodInfo, Type tupleType)`. + +When an assertion compares against a recorded `InvocationRecord`, the harness: +1. Resolves the assertion's `MethodInfo` to a method ID via the map. +2. Filters records by method ID. +3. For arg-matching records, deserializes raw args using the tuple type, compares each tuple element via `ArgMatcher`. + +**Note:** Method-ID derivation must match the generator. `TypeHasher` is Roslyn-only, so the harness derives the same ID by reading the *generated* `IInvocationMethodHash` implementations on the proxy/nexus types (the source generator emits these). The harness reflects on generated classes, not on user interfaces, to retrieve actual method IDs. + +**Files:** all new under `src/NexNet.Testing/Quiescence/` and `src/NexNet.Testing/Recording/`. + +**Tests:** Unit tests for `QuiescenceTracker` (counter races, observe-zero correctness, signal-after-decrement). Unit tests for the method-ID resolution map. Tests combining a fake interceptor invocation log with `QuiesceAsync`. + +## Phase 9 — `TappingPipeReader/Writer`, `PipeRecording`, `TestPipeFactory`, `activePipes` counter + +Implement the pipe tap layer: + +``` +src/NexNet.Testing/ +├── Streaming/ +│ ├── TappingPipeReader.cs (records on AdvanceTo) +│ ├── TappingPipeWriter.cs (records on FlushAsync) +│ ├── PipeRecording.cs (ConsumedBytes, WrittenBytes, IsCompleted, FaultedWith, +│ │ WaitForBytesAsync, WaitForCompletionAsync) +│ └── TestPipeFactory.cs (IPipeFactory impl: wraps inner pipe, registers recording, +│ increments activePipes on rent, decrements on CompleteTask) +``` + +`TappingPipeReader` extends `PipeReader`. Tracks last-returned buffer; on `AdvanceTo(consumed)`, computes the slice from prior consumed-watermark to new position, copies to `PipeRecording.ConsumedBytes`. `TappingPipeWriter` extends `PipeWriter`; records each `FlushAsync`-committed run. + +`TestPipeFactory.WrapLocal` / `WrapRemote` create a small `INexusDuplexPipe` proxy that delegates everything to the inner pipe but substitutes `Input` and `Output` with tap wrappers. Hooks the inner pipe's `CompleteTask.ContinueWith(_ => Decrement(activePipes); SignalChange())` so the quiescence counter is correct even if the user code abandons a pipe. + +**Files:** all new under `src/NexNet.Testing/Streaming/`. + +**Tests:** Unit tests for `TappingPipeReader` / `TappingPipeWriter` (consumed-vs-visible recording, bidirectional duplex, completion propagation, exception capture). Test that `TestPipeFactory` correctly increments / decrements `activePipes` across rent / complete / abort cycles. + +## Phase 10 — `NexusTestHost`, `NexusTestClient`, `ConnectAsync`, auth integration + +Wire everything together into the entry-point API: + +``` +src/NexNet.Testing/ +├── NexusTestHost.cs (static Create(opts)) +├── NexusTestHost`2.cs (NexusTestHost) +├── NexusTestClient`2.cs (NexusTestClient) +├── NexusTestHostOptions.cs (ServerNexusFactory, ClientNexusFactory, identities) +├── Authentication/ +│ ├── TestIdentity.cs (Of(name, roles) → IIdentity + opaque token) +│ └── TestAuthenticationStore.cs (token bytes ↔ IIdentity registry, used by override) +``` + +`NexusTestHost.Create(configure)` constructs an `InProcessServerConfig` with `OnAuthenticateOverride` pointing at the harness's auth store, installs the `TestInvocationInterceptor` and `TestPipeFactory` on `ConfigBase`, starts the server. Returns `NexusTestHost`. + +`ConnectAsync(asUser?, roles?)` issues a `TestIdentity.Of(...)` token, stamps it on a fresh `InProcessClientConfig.Authenticate = () => token`, constructs a client via the user's generated `CreateClient` factory, connects, and returns a `NexusTestClient` handle exposing `.Server` (proxy), `.Nexus` (the user's client nexus instance), `.SessionId` (resolved post-connect), and the per-session `InvocationRecorder`. + +`QuiesceAsync()` delegates to the host's `QuiescenceTracker`. + +**Files:** all new under `src/NexNet.Testing/`. + +**Tests:** A minimal end-to-end test: spin up a host with a trivial test nexus, connect, invoke a server method, await `QuiesceAsync`, verify the invocation record exists. A multi-client test exercising 5+ concurrent connections. + +## Phase 11 — Streaming helpers, `ChannelRecording`, `TapChannel` + +Add the convenience-helper extension methods on `NexusTestHost`: + +``` +src/NexNet.Testing/ +├── Streaming/ +│ ├── ChannelRecording.cs (Items, IsCompleted, WaitForCountAsync) +│ ├── PipeStreamingExtensions.cs (PipeUpload, PipeDownload) +│ └── ChannelStreamingExtensions.cs (ChannelCollect, ChannelPublish, TapChannel) +``` + +Each helper signature follows the form `Func invoke` or `Func, ValueTask> invoke` — a delegate the user supplies that calls the proxy method with the harness-provided pipe/channel. + +`PipeUpload` creates a pipe via the proxy's pipe-creation API, kicks off `invoke(pipe)`, writes `data` to `pipe.Output`, completes, awaits the invocation. `PipeDownload` is the mirror — invoke, read until complete, return collected bytes. `ChannelCollect` / `ChannelPublish` are the typed channel equivalents. `TapChannel(invoke)` returns a `ChannelRecording` wired to a tap-wrapped channel. + +**Files:** all new. + +**Tests:** Each helper covered by a focused test on a representative pipe/channel-using nexus method. + +## Phase 12 — `AssertReceived` / `AssertNotReceived` / `WaitFor` + +Add the assertion API as instance methods on `NexusTestClient` (and a server-recorder variant on `NexusTestHost` for "what did the server see"): + +```csharp +public void AssertReceived(Expression> expr, int times = 1); +public void AssertNotReceived(Expression> expr); +public Task WaitFor(Expression> expr, TimeSpan? timeout = null); +``` + +`AssertReceived` (sync, post-`QuiesceAsync`): parses `expr` via `ExpressionParser`, looks up method ID, scans the recorder for matches, throws `NexusAssertionException` with a diagnostic message on mismatch. Includes the actual recorded arg values vs. the expected matchers in the message. + +`AssertNotReceived` (sync, post-`QuiesceAsync`): expects zero matches; throws if any found. + +`WaitFor` (async, with timeout): awaits a match arriving in the recorder up to `timeout` (default 5 seconds). Implemented via the recorder's change signal. Throws `TimeoutException` (which test frameworks already surface as a meaningful failure) if no match arrives. + +Diagnostic messages must include: the expected method, expected matchers (rendered via `ArgMatcher.ToString()`), and a list of nearby recorded invocations (e.g., the most recent N on the same interface) to aid debugging. + +**Files:** new on `NexusTestClient`, `NexusTestHost`. Diagnostics rendering helpers under `src/NexNet.Testing/Recording/`. + +**Tests:** Unit tests for each of the three APIs, including: positive/negative matches, wildcard matchers, predicate matchers, multi-arg methods, the times-mismatch branch of `AssertReceived`, the timeout branch of `WaitFor`, the diagnostic message content. + +## Phase 13 — Group introspection + end-to-end harness samples + +Expose group membership for assertions: + +```csharp +// On NexusTestHost: +public IReadOnlyDictionary Groups { get; } + +public interface IGroupView +{ + IReadOnlyList Members { get; } +} +``` + +Implementation reads from the server's `LocalGroupRegistry.GetLocalGroupMembers(name)` (returns `IEnumerable`) and projects to session IDs. + +Add an end-to-end sample test class `HarnessShowcaseTests.cs` in `NexNet.Testing.Tests` demonstrating each broadcast pattern from the design discussion: `GroupExceptCaller`, `AllExcept`, `Client(id)`, bulk broadcast over 50 clients. These also serve as in-tree documentation. + +**Files:** group-introspection additions on `NexusTestHost`, the showcase test class. + +**Tests:** The showcase class is itself the test surface — each broadcast pattern verified end-to-end. + +## Phase 14: Showcase rewrite — document-editor demo + +Added 2026-05-27 after PR #77 review feedback. The PR-sample broadcast pattern (`alice.Server.JoinGroup("editors"); alice.Server.BroadcastToGroup("editors", "draft-saved")`) reflected harness ergonomics in the worst light by forcing users to write passthrough methods on their server nexus just to drive broadcast tests. Real NexNet code invokes typed callbacks directly on group proxies (`Context.Clients.Group($"doc-{docId}").DraftSaved(...)`) inside business methods. The fix is to replace the demo nexus with a realistic domain whose internal use of `Context.Groups` / `Context.Clients` is incidental to the business verb, and rewrite the showcase tests to drive those verbs and assert on observed client callbacks. No new harness API is added. + +### Design + +**Domain.** Collaborative document editor. Server-side methods, each with a business reason that internally exercises one or two NexNet features: + +| Method | NexNet feature exercised | +|---|---| +| `OpenDocument(docId)` | `Context.Groups.AddAsync` + broadcasts `EditorJoined(name)` via `Context.Clients.GroupExceptCaller` | +| `LeaveDocument(docId)` | `Context.Groups.RemoveAsync` + broadcasts `EditorLeft(name)` via `GroupExceptCaller` | +| `SaveDraft(docId, content)` | `[NexusAuthorize(Write)]` + `Context.Clients.Group(...).DraftSaved(Context.Identity?.DisplayName, content)` | +| `Whisper(targetId, text)` | `Context.Clients.Client(targetId).WhisperReceived(...)` direct targeting | +| `BroadcastSystemAnnouncement(msg)` | `[NexusAuthorize(Admin)]` + `Context.Clients.All.SystemAnnouncement(msg)` | +| `ListActiveEditors(docId, ct)` | `ValueTask` return + `CancellationToken` propagation; reads display names from sessions in the group | +| `UploadAttachment(docId, INexusDuplexPipe pipe)` | Pipe byte-streaming consumed by server; bytes appended to doc state | +| `StreamEdits(INexusDuplexChannel channel)` | Channel typed-streaming consumed by server; ops appended to doc state | + +**Permissions.** `enum DocPermission { Read, Write, Admin }`. `OnAuthorize` override: + +```csharp +protected override ValueTask OnAuthorize( + ServerSessionContext context, int methodId, string methodName, + ReadOnlyMemory requiredPermissions) +{ + if (context.Identity is not TestIdentity id) + return new(AuthorizeResult.Unauthorized); + foreach (var p in requiredPermissions.Span) { + var roleName = ((DocPermission)p).ToString(); + if (!id.IsInRole(roleName)) + return new(AuthorizeResult.Unauthorized); + } + return new(AuthorizeResult.Allowed); +} +``` + +Role matching: case-sensitive ordinal, role-name string == enum-member name. Tests connect with `TestIdentity.Of("alice", "Write", "Admin")`. + +**Client callbacks.** `IEditorClientNexus`: `DraftSaved(string author, string content)`, `EditorJoined(string author)`, `EditorLeft(string author)`, `WhisperReceived(string from, string text)`, `SystemAnnouncement(string message)`. + +**Cross-session state.** `EditorServerNexus` holds a static `ConcurrentDictionary` keyed by `docId`. `DocState` carries appended attachment bytes and edit ops for verification in tests. State is process-global; each `[Test]` clears the dictionary at the top (matches the existing `LastUploadedBytes` / `PingCount` pattern). + +**`EditOp` type.** `record struct EditOp(int Position, string Inserted)` with `[MemoryPackable]`. Channel: `INexusDuplexChannel` (managed; string field rules out unmanaged channel). + +**`ListActiveEditors` impl.** Reads sessions in the doc group via the *server-side* equivalent of `host.Groups[...]`. Inside a method, `Context` doesn't expose the registry directly — but iterating `Context.Clients.GetIds()` and filtering by group membership is awkward. Cleaner: walk `Context.Group.GetNamesAsync()` is per-session not server-wide. Simplest path: maintain a parallel `ConcurrentDictionary>` (docId → sessionId → name) on the server nexus that `OpenDocument`/`LeaveDocument` mutate. Return its values for the docId. + +### Rename impact + +Rename `DemoServerNexus`/`DemoClientNexus`/`IDemoServerNexus`/`IDemoClientNexus` → `EditorServerNexus`/`EditorClientNexus`/`IEditorServerNexus`/`IEditorClientNexus`. Rename `HarnessSampleNexus.cs` → `EditorAppNexus.cs`. Touches: + +- `HarnessSampleNexus.cs` (rebuild as `EditorAppNexus.cs`) +- `AssertionTests.cs`, `NexusTestHostTests.cs`, `ClientAssertionTests.cs`, `HarnessShowcaseTests.cs`, `StreamingExtensionsTests.cs`, `MethodIdMapTests.cs` + +`MethodIdMapTests` pins specific method IDs against the demo interface. The generator assigns IDs by declaration order; adding new methods after the existing 8 means the existing pinned IDs (Ping=1..PublishStrings=8 in current ordering, or whatever they actually are) stay stable. The new methods get the next IDs (9..16). The test's expected-IDs dictionary will be extended; existing entries do not change values. + +### Showcase tests + +New file `EditorAppShowcaseTests.cs` in `NexNet.Testing.Tests`. Each test focused on one observable harness capability: + +1. `SaveDraft_BroadcastsToDocGroup_WithAuthorIdentity` — Alice + Bob open `design.md`; Carol opens `recipe.txt`. Alice saves. Bob receives `DraftSaved("alice", "v1")`; Carol does not. Pins identity flow + group routing + per-client `AssertReceived`. +2. `LeaveDocument_NotifiesOthersExceptCaller` — Alice + Bob + Carol all open `design.md`. Bob leaves. Alice and Carol receive `EditorLeft("bob")`; Bob does not. Pins `GroupExceptCaller`. +3. `Whisper_DeliveredToTargetOnly` — Alice whispers Bob. Bob receives `WhisperReceived("alice", "hi")`; Carol does not. Pins `Client(id)`. +4. `BroadcastSystemAnnouncement_AsNonAdmin_Throws` — Alice (no Admin role) calls `BroadcastSystemAnnouncement`. Throws `ProxyUnauthorizedException`. Pins `[NexusAuthorize]` + `OnAuthorize`. +5. `BroadcastSystemAnnouncement_AsAdmin_DeliversToAll` — Connected as admin. Every connected client receives `SystemAnnouncement`. Pins `Context.Clients.All`. +6. `SaveDraft_AsReader_Throws` — Alice connects without `Write` role. `SaveDraft` throws unauthorized. +7. `ListActiveEditors_ReturnsNames` — Three editors open `design.md`. `ListActiveEditors("design.md", default)` returns `["alice","bob","carol"]` (equivalent-to assertion, order-independent). Pins `ValueTask` return shape. +8. `ListActiveEditors_CancelledToken_Throws` — Token canceled before invoke. Server method observes cancellation and throws. Pins CT propagation. +9. `UploadAttachment_StreamsBytes_ServerAppendsToDoc` — Alice uploads 4 KiB to `design.md`. Server stores bytes in doc state; assert byte count + content equality. +10. `StreamEdits_AllOpsCollected` — Alice streams 10 `EditOp` values. Server appends to doc state. Assert all 10 are present and ordered. +11. `WaitFor_DraftSaved_ResolvesWhenInvoked` — Bob calls `SaveDraft` in background. Alice `WaitFor`s the callback before `QuiesceAsync`. Resolves once the broadcast arrives. +12. `WaitFor_Timeout_Throws` — Nobody saves. Alice's `WaitFor` with 250ms timeout throws. +13. `MixedTraffic_QuiesceAsync_WaitsForEverything` — 3 clients fire SaveDraft + UploadAttachment + StreamEdits + Whisper concurrently. `QuiesceAsync` returns; all per-client assertions pass with the expected counts. +14. `Groups_Introspection_ReflectsLiveMembership` — Open / leave / open across multiple docs; `host.Groups["doc-design.md"]` reflects current set. +15. `Groups_EmptyGroup_HasNoMembers` — Reused from old showcase, fits the new file. +16. `ServerSide_AssertReceived_SaveDraft` — Use `host.AssertReceived(n => n.SaveDraft("design.md", "v1"))`. Pins server recorder. +17. `AssertReceived_TimesMismatch_DiagnosticIncludesArgs` — Save twice, assert once. Pin R7 contract: exception message lists actual arg values, e.g., `SaveDraft("design.md","v1"); SaveDraft("design.md","v2")` not `#1, #1`. +18. `ArgMatchers_AnyAndPredicate` — `Arg.Any()` and `Arg.Is(s => s.StartsWith("v"))` against `DraftSaved`. + +### README / pr-body sample + +Short (~20 lines), drawn from test #1 above: + +```csharp +await using var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>(); + +var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); +var bob = await host.ConnectAsAsync(TestIdentity.Of("bob", "Write")); +var carol = await host.ConnectAsAsync(TestIdentity.Of("carol", "Read")); + +await alice.Server.OpenDocument("design.md"); +await bob.Server.OpenDocument("design.md"); +await carol.Server.OpenDocument("recipe.txt"); + +await alice.Server.SaveDraft("design.md", "v1"); +await host.QuiesceAsync(); + +bob.AssertReceived(n => n.DraftSaved("alice", "v1")); +carol.AssertNotReceived( + n => n.DraftSaved(Arg.Any(), Arg.Any())); +Assert.That(host.Groups["doc-design.md"].Count, Is.EqualTo(2)); +``` + +### Test plan for Phase 14 + +The 18 new tests above ARE the tests for this phase. Existing `NexNet.Testing.Tests` (65 cases as of R12) must continue to pass after the rename — that's the regression bar. Adding the editor showcase brings the count to ~80 cases. Existing `NexNet.IntegrationTests` (2825 cases) is unaffected by this phase. + +### Method-ID treatment + +The generator assigns method IDs by declaration order in the interface. Current `IDemoServerNexus` IDs (1=Ping, 2=Notify, 3=JoinGroup, 4=BroadcastToGroup, 5=Upload, 6=Download, 7=CollectStrings, 8=PublishStrings) are pinned by `MethodIdMapTests`. The rewrite deletes `JoinGroup` and `BroadcastToGroup` (the passthrough methods the user called out), shifting Upload..PublishStrings from 5..8 to 3..6, then appends the editor methods (`OpenDocument`..`StreamEdits`) at 7..14. `MethodIdMapTests` gets a single coherent ID layout update covering both the shift and the new methods. + +### Sequencing + +14a. Build the new `EditorServerNexus`/`EditorClientNexus` in `EditorAppNexus.cs` (replacing `HarnessSampleNexus.cs`): all kept methods (Ping, Notify, Upload, Download, CollectStrings, PublishStrings) plus all new editor methods (OpenDocument, LeaveDocument, SaveDraft, Whisper, BroadcastSystemAnnouncement, ListActiveEditors, UploadAttachment, StreamEdits). Include `DocPermission` enum, `OnAuthorize` override, `EditOp` struct, static doc-state dictionary. JoinGroup/BroadcastToGroup deliberately omitted. + +14b. Update all dependent test files to use the new type names: `AssertionTests`, `NexusTestHostTests`, `ClientAssertionTests`, `StreamingExtensionsTests`, `MethodIdMapTests`. Update the expected-IDs map in `MethodIdMapTests` to reflect the new method layout. Delete `HarnessSampleNexus.cs`. Run full `NexNet.Testing.Tests` — expected outcome: 63/63 green (was 65 minus the two `HarnessShowcaseTests` cases to be dropped, since `HarnessShowcaseTests.cs` itself is replaced in 14c). Actually — 14b only renames; 14c handles the showcase replacement, so at end of 14b the suite is 65/65 with `HarnessShowcaseTests` updated to use new type names temporarily. + +14c. Replace `HarnessShowcaseTests.cs` with the new `EditorAppShowcaseTests.cs` containing tests 1–10 from the test list. Migrate `Groups_EmptyGroup_HasNoMembers` over. Drop the two superseded tests. Run tests. + +14d. Add tests 11–18 (WaitFor, mixed-traffic quiescence, server-side AssertReceived, times-mismatch diagnostic, ArgMatchers). + +14e. Update the README sample (search for current sample location) and `_sessions/add-nexnet-testing/pr-body.md` to use the new code shape. + +Each sub-step is independently committable. If quiescence or auth-flow integration surfaces unexpected behavior in 14c, that's the right moment to find it — well before later showcase tests bake in expectations. + +## Out of scope (deferred) + +- **TimeProvider integration** — issue #75. +- **Inner-layer `IInvocationInterceptor`** with typed args (would require generator changes). Outer layer is sufficient for v1. +- **Channel factory hook in core.** v1 ships helpers-only. +- **Test-framework-specific sugar** (NUnit/xUnit attributes). Core API throws standard exceptions; users wrap as needed. +- **`FakeTimeProvider`-driven tests of auth-cache TTL, ping, reconnect.** Blocked on #75. +- **`host.Clients` direct broadcast surface / `client.JoinGroupAsync` direct membership** — explicitly rejected in Phase 14 design as they would conflict with the "drive real business methods, observe callbacks" pattern. + +## Phases-total: 14 diff --git a/_sessions/add-nexnet-testing/pr-body.md b/_sessions/add-nexnet-testing/pr-body.md new file mode 100644 index 00000000..2811ee0d --- /dev/null +++ b/_sessions/add-nexnet-testing/pr-body.md @@ -0,0 +1,136 @@ +## Summary +- Ships a new `NexNet.Testing` package: in-process transport, test host, recorders, server-side and per-client assertion APIs, streaming-helper extensions, and a quiescence primitive. +- Adds three minimal optional internal hooks to `NexNet` core (`IInvocationInterceptor`, `IPipeFactory`, `ServerConfig.OnAuthenticateOverride`) so the harness can instrument dispatch and auth without touching production hot paths. +- Demo + showcase nexus rewritten in Phase 14 from the original `JoinGroup`/`BroadcastToGroup` passthrough shape to a realistic `EditorServerNexus` document-editor domain that drives every harness feature through natural business verbs. +- Driven by 14 plan phases + 12 remediation phases (R1–R12) addressing 40 findings from a structured review, plus a second full-branch review pass addressing 5 more findings. + +## Reason for Change + +End users building applications on NexNet need a way to test their nexus implementations — server methods, client callbacks, authorization rules, broadcasts, group routing, pipes, and channels — without standing up sockets, ports, TLS, or fake auth providers. Existing options either require real network plumbing or fork into custom test harnesses per project. + +## Impact + +New consumers of the package can write tests like: + +```csharp +await using var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>(); + +var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); +var bob = await host.ConnectAsAsync(TestIdentity.Of("bob", "Write")); +var carol = await host.ConnectAsAsync(TestIdentity.Of("carol", "Read")); + +await alice.Server.OpenDocument("design.md"); +await bob.Server.OpenDocument("design.md"); +await carol.Server.OpenDocument("recipe.txt"); + +await alice.Server.SaveDraft("design.md", "v1"); +await host.QuiesceAsync(); + +bob.AssertReceived(n => n.DraftSaved("alice", "v1")); +carol.AssertNotReceived( + n => n.DraftSaved(Arg.Any(), Arg.Any())); +Assert.That(host.Groups["doc-design.md"].Count, Is.EqualTo(2)); +``` + +The sample drives real business methods (`OpenDocument`, `SaveDraft`) instead of synthetic passthroughs — `SaveDraft` is `[NexusAuthorize(Write)]`-gated and broadcasts `DraftSaved` to `Context.Clients.Group($"doc-{docId}")` internally. Tests observe the resulting client callbacks plus the recorded server-side invocation. + +Existing NexNet consumers are unaffected: every new hook is `internal` (with InternalsVisibleTo for `NexNet.Testing`) and defaults to `null` so production sessions keep the unchanged dispatch path. + +## Plan items implemented as specified + +- **Phase 1** — `PendingInvocationCount` accessor on `ISessionInvocationStateManager`. +- **Phase 2** — `IInvocationInterceptor` interface + ConfigBase / NexusSessionConfigurations plumbing + Receiving wire-up. +- **Phase 3** — `IPipeFactory` interface + WrapLocal / WrapRemote hook points in `NexusPipeManager`. +- **Phase 4** — `ServerConfig.OnAuthenticateOverride` + `ServerNexusBase.Authenticate` consult-then-fallback. +- **Phase 5** — `NexNet.Testing` project with `InProcessTransport` (paired Pipes cross-wired), listener with Channel-based accept queue, `InProcessServerConfig`/`ClientConfig`, and `InProcessRendezvous`. +- **Phase 6** — `Type.InProcess` enum value + integration-test config branches (further expanded in R10). +- **Phase 7** — Recorder primitives (`InvocationRecorder`, `Arg.Any()`/`Arg.Is(predicate)` sentinels, `ArgMatcher`, `ExpressionParser`, `NexusAssertionException`). +- **Phase 8** — `TestInvocationInterceptor`, `QuiescenceCounters`, `QuiescenceTracker` with observe-zero/yield/re-observe pattern. +- **Phase 9** — `PipeRecording`, `TappingPipeReader`/`TappingPipeWriter`, `TappedNexusDuplexPipe`, `TestPipeFactory`. +- **Phase 10** — `NexusTestHost.CreateAsync` static entry point, `NexusTestHost<...>`, `NexusTestClient<...>`, `TestIdentity.Of`, `TestAuthenticationStore`. +- **Phase 12** — Server-side `AssertReceived`/`AssertNotReceived`/`WaitFor` on the host with `MethodIdMap` + `ArgumentDeserializer`. +- **Phase 14** — Demo + showcase rewrite (added after first review pass). The old `DemoServerNexus` had passthrough methods (`JoinGroup`/`BroadcastToGroup`) that showed harness ergonomics in the worst light by forcing users to write passthrough plumbing just to test broadcasts. Replaced with a realistic `EditorServerNexus` document-editor domain (`OpenDocument`, `LeaveDocument`, `SaveDraft` [Write], `Whisper`, `BroadcastSystemAnnouncement` [Admin], `ListActiveEditors`, `UploadAttachment`, `StreamEdits`) that exercises every harness feature through natural business verbs. `OnAuthorize` override matches `TestIdentity.IsInRole` case-sensitive ordinal against `DocPermission` enum names. `EditorAppShowcaseTests.cs` adds 18 focused tests; existing dependent tests (`AssertionTests`, `ClientAssertionTests`, `NexusTestHostTests`, `StreamingExtensionsTests`, `MethodIdMapTests`) updated to the new domain; `HarnessSampleNexus.cs` + `HarnessShowcaseTests.cs` removed. + +## Deviations from plan implemented + +- **Hook reduction.** Plan called for three core factories (invocation interceptor + pipe factory + channel factory). Channels do not need a core factory in v1 — harness convenience helpers (`ChannelPublishAsync`/`ChannelCollectAsync`) own channel creation directly. The pipe-layer byte tap still observes any user-instantiated channels (Decisions §"Revisions after source verification"). +- **Hook location on `ConfigBase`.** Plan considered direct fields on `NexusSessionConfigurations`. Implementation stores them on `ConfigBase` (authoritative install point) and copies into the per-session struct at construction. Users install hooks once on the config and every session inherits them. +- **Transport home.** Plan started with `MemoryTransport` in NexNet core; revised to `InProcessTransport` shipped in `NexNet.Testing` so production code never has to take a test-package dependency. The integration-test-validation argument is preserved by `NexNet.IntegrationTests` taking a project reference to `NexNet.Testing` and exercising the InProcess transport through the existing `[TestCase]` matrix. +- **TimeProvider integration deferred** to follow-up issue #75. Verification revealed the actual scope (14 time references + 5 timers + 9 `Task.Delay` calls + an existing `TickCountOverride` test seam) was large enough to warrant its own focused workflow. The harness ships without time control as a known limitation; tests of time-sensitive logic (auth cache TTL, reconnect, ping) remain real-time-bound until #75 lands. + +## Gaps in original plan implemented + +These were uncovered during REVIEW after the 13-phase IMPLEMENT pass and addressed in the R1–R12 remediation: + +- **Quiescence counters were dead code.** Two of four counters (`bytesInTransit`, `pendingResults`) had no production wire-up at all — the unit tests called the mutators directly but real session activity never did. R1 fixed this with `CountingPipeWriter`/`CountingPipeReader` in `InProcessTransport` and per-session `InternalOnSessionSetup` callbacks on both server and client configs. +- **Multi-client `ConnectAsAsync` hang.** Phase 13 was reduced-scope because a second connect against the same host hung. R2 traced this to user-supplied factories returning a shared nexus instance corrupting `SessionContext`; the harness now detects this and throws a clear error instead of hanging. Multi-client tests (3 clients + group broadcast) added in R3. +- **Group introspection** (`host.Groups[name].Members`) added in R3 — previously deferred because of the multi-client hang. +- **Streaming-helper extensions** (`PipeUploadAsync`, `PipeDownloadAsync`, `ChannelPublishAsync`, `ChannelCollectAsync`) added in R4 (plan §11). +- **Per-client assertion API** on `NexusTestClient` added in R5 (plan §12). +- **`MethodIdMap` generator parity** — runtime build now uses `BindingFlags.DeclaredOnly` + alphabetically-sorted inherited interfaces (no `.Distinct()`), matching the generator's `OrderBy(i => i.ToDisplayString())` ordering. Pinned by new `MethodIdMapTests`. (R6) +- **Generator-aligned arg deserializer filter** — replaced namespace-prefix heuristic with exact-FullName matches against the generator's exclusion list. (R7) +- **Mismatch diagnostics include arg values** — `AssertReceived` failures now render `Notify("alpha"); Notify("beta")` instead of `#1, #1`. (R7) +- **Multi-arg + string-arg AssertionTests** added (R7), and the synthetic ExpressionParser test replaced with a real `n => n.IntValue` property-access case (R10). +- **Tap continuation deduplication** in `TappedNexusDuplexPipe` (R8); `WaitFor` switched to `Environment.TickCount64` monotonic clock (R8). +- **PredicateMatcher exception surfacing** — user-thrown predicate exceptions are now reported as `NexusAssertionException` with inner exception preserved, not silently masked. (R9) +- **Integration matrix expansion** — `[TestCase(Type.InProcess)]` added to all three hook test classes plus pipes, channels, collections, groups, cancellation, and invalid-invocations (+83 InProcess test cases, R10). +- **Polish**: `TestAuthenticationStore.OverrideDelegate` cached as a field; `NexusTestHost.DisposeAsync` logs teardown errors instead of swallowing; `InProcessRendezvous.Unregister` rewritten with `TryGetValue`+`ReferenceEquals`+`TryRemove`. (R11) +- **API ergonomics**: public `NexusTestHost.RecordedServerInvocationCount`; per-typeparam XML docs on `CreateAsync`; AppDomain/ALC scope documented on `InProcessRendezvous`. (R12) + +See `_sessions/add-nexnet-testing/review.md` for the full 40-finding classification table and per-finding remediation notes. + +## Second review pass (full branch re-review) + +A second structured review over the full branch diff (after the first finalize gate) surfaced 8 findings (1 Med, 7 Low). The 5 actionable items were fixed in-branch; 3 are documented/tracked (no action). See `review.md` §"Full branch re-analysis (Session 8)" for the classification table. + +- **MethodIdMap ignored-method parity (Med).** The runtime map now filters `[NexusMethod(Ignore=true)]` methods before assigning ids, matching the generator's pre-`AssignMethodIds` filter — previously an ignored method would shift the ids of methods declared after it, silently misaligning `AssertReceived`/`AssertNotReceived`. Regression test added. +- **MethodIdMap generic-interface ordering.** Inherited interfaces are now ordered by a C#-style display name matching the generator's `ToDisplayString()` ordinal, rather than `Type.FullName` (which diverges for generic/nested interfaces). Regression test added (`IGen` vs `IGen`). +- **Orphaned public surface.** `ChannelRecording` was demoted from public to internal: no harness producer (`TapChannel`) ships in v1, so it had no way to be populated. Internalizing keeps a non-breaking public promotion open for later. +- **Dead code.** Deleted the unused `TappedRentedNexusDuplexPipe` (local pipes pass through unwrapped since R4) and corrected the `TestPipeFactory` doc. +- **Package README.** `NexNet.Testing` now ships a harness-specific NuGet README instead of inheriting the core-NexNet README. + +Documented/tracked (no action): the aggregate (host-global) quiescence counter model; the `Task.Run` fire-and-forget quiescence residual; wall-clock-bound timing tests (deterministic time control tracked by #75). + +## Migration Steps + +None for existing consumers. To adopt the harness: + +1. Add a project reference to `NexNet.Testing` from the test project. +2. Replace handcrafted socket setup with `await using var host = await NexusTestHost.CreateAsync<...>();`. +3. Use `client.Server`/`client.Nexus`/`client.AssertReceived(...)` and `host.AssertReceived(...)` for assertions. + +## Performance Considerations + +- Production hot paths take three nullability checks (`_invocationInterceptor`, `_pipeFactory`, `OnAuthenticateOverride`). All default to `null` in non-harness code; the JIT specializes the branch. +- `TappingPipeWriter.GetSpan` re-routes through `GetMemory` so the tap can read the source; cost is only paid on tapped (test-only) pipes. +- Quiescence counter increments are `Interlocked.*` ops on the shared host counter; one per byte movement / dispatch / pipe-open. Test-only overhead. + +## Security Considerations + +- All three new core hooks are `internal` with `[InternalsVisibleTo("NexNet.Testing")]`; promotion to public is a v1.x decision when external demand exists. +- The `NexNet.csproj` grant to `NexNet.Testing.Tests` was removed in R9 — the test project now reaches NexNet internals only through `NexNet.Testing`'s intended surface. +- `OnAuthenticateOverride` is server-side only; the harness installs it via the public-readable `InProcessServerConfig.OnAuthenticateOverride` property. Production code that doesn't set this property keeps the existing `OnAuthenticate`-only path. +- `TestAuthenticationStore` accumulates tokens for the host's lifetime by design (the store can't tell which tokens are still in use). Documented in XML; `Clear()` added for long-lived hosts. +- `ArgumentDeserializer` only ever runs on bytes the session has already validated — it doesn't ingest untrusted input. +- `ArgMatcher.PredicateMatcher` no longer swallows predicate-thrown exceptions silently; they surface as `NexusAssertionException` with inner-exception preserved. + +## Breaking Changes + +### Consumer-facing +- None for application code. +- **New public surface in `NexNet.Testing` is v1** — `NexusTestHost`, `NexusTestClient`, `NexusAssertionException`, `Arg.Any()`, `Arg.Is(...)`, `TestIdentity.Of(...)`, `PipeRecording`, `GroupView`, `GroupIntrospector`, `StreamingExtensions`. Future iteration may add overloads non-breakingly. (`ChannelRecording` was demoted to internal in the second review pass — see below — until a public `TapChannel` producer ships.) + +### Internal +- `ConfigBase` gains `internal IInvocationInterceptor?` and `internal IPipeFactory?` properties. +- `ServerConfig` gains `internal Func?, ValueTask>? OnAuthenticateOverride`. +- `ISessionInvocationStateManager` gains `int PendingInvocationCount { get; }`. +- `NexusServer` gains `internal IServerSessionManager? SessionManagerInternal`. +- `NexNet.csproj` `InternalsVisibleTo` grants reshuffled — `NexNet.Testing` grant moved to source attribute; `NexNet.Testing.Tests` grant removed. + +## Test plan +- [ ] CI matrix passes (Generator + IntegrationTests + Testing tests). +- [ ] All hook tests run on both `Type.Tcp` and `Type.InProcess`. +- [ ] All pipe/channel/collection/group tests now exercise `Type.InProcess`. +- [ ] `NexNet.Testing.Tests` (84 tests) covers transport, recorder, MethodIdMap parity (declaration order, explicit ids, ignored-method filtering, and generic-inherited-interface ordering), quiescence under real load, host construction with multi-client connect and shared-instance detection, group introspection + broadcast, streaming helpers (upload/download/publish/collect), per-client assertions, and the 18-test `EditorAppShowcaseTests` covering identity flow, authorization (Write/Admin gates), `GroupExceptCaller`/`Client(id)`/`All` routing, pipe + channel streaming, mixed-traffic quiescence, and `Arg.Any`/`Arg.Is` matchers. diff --git a/_sessions/add-nexnet-testing/review.md b/_sessions/add-nexnet-testing/review.md new file mode 100644 index 00000000..071d223b --- /dev/null +++ b/_sessions/add-nexnet-testing/review.md @@ -0,0 +1,226 @@ +# Review: Full branch re-analysis (Session 8, PR #77) + +Analyzed: `origin/master...HEAD` (44 commits, 93 files, ~5,400 insertions). Date: 2026-05-28. Re-entered REVIEW from the FINALIZE gate at user request (full branch re-analysis). Build clean (0 warnings); `NexNet.Testing.Tests` 82/82 green; core-hook integration classes 16/16 on Tcp+InProcess; `dotnet pack` succeeds. **Regression check: all load-bearing prior fixes (R1–R12) survived the Session-7 rebase intact** (full list in the regression section below). + +## Classifications + +8 findings after consolidating cross-section overlaps (the `ChannelRecording` orphan appears in Plan/Test/Consistency; the MethodIdMap parity gaps appear in Correctness/Test — each is one finding here). 1 Med, 7 Low. No High. + +| # | Class | Rec | Sev | Section | Finding | Action Taken | +|---|-------|-----|-----|---------|---------|--------------| +| 1 | A | A | Low | Plan/Test/Consistency | `ChannelRecording` ships as public packaged surface with no producer — `TapChannel` (Phase 11) was never built (R4 deferred it); only its own unit test constructs one. Orphaned public one-way-door. | Demoted `ChannelRecording` from `public` to `internal` (no harness producer ships in v1; promoting later is non-breaking, demoting a shipped public type is not). Its internal unit test still compiles via the existing `InternalsVisibleTo("NexNet.Testing.Tests")` grant. Added an XML remark explaining the deferral. | +| 2 | D | D | Low | Plan | Quiescence `QuiescenceCounters` are shared at session-key `0` (host ctor + every `ConnectAsAsync`), not per-session as plan/Decisions describe. `GetCountersFor(sessionId)` is effectively unused beyond key 0. Functionally nets to zero; inline comment at `NexusTestHost.cs:199-203` already acknowledges it. | Not actioned (D). The aggregate (key-0) counter model is functionally correct — increments/decrements net to zero across sessions — and the inline comment already documents it. Per-session counter isolation is not needed in v1; making it truly per-session is a refactor with no behavioral benefit today. | +| 3 | A | A | Med | Correctness/Test | `MethodIdMap.Build` does not filter `[NexusMethod(Ignore=true)]` methods, but the generator's `NexusDataExtractor` filters them *before* `AssignMethodIds`. On an interface with an ignored method, runtime ids shift relative to the generator → `AssertReceived`/`AssertNotReceived` silently resolve the wrong method. Demo uses no ignored methods, so untested/latent. | `MethodIdMap.CollectDeclaredMethods` now filters `[NexusMethod(Ignore=true)]` (via `NotIgnored`) on both direct and inherited methods, matching the generator's pre-`AssignMethodIds` filter — ignored methods get no id and reserve no slot. New regression test `Build_ExcludesIgnoredMethods_AndDoesNotShiftFollowingIds` pins First=0/Third=1/Ignored-absent. | +| 4 | A | C | Low | Correctness/Test | `MethodIdMap` orders inherited interfaces by `Type.FullName` (`:71`) while the generator orders by `INamedTypeSymbol.ToDisplayString()` (`NexusDataExtractor.cs:154`). Keys differ for *generic* inherited interfaces, shifting ids. Demo declares all methods directly, so untested/latent. | `MethodIdMap` now orders inherited interfaces by a C#-style display-name key (`GeneratorOrderingKey`/`AppendDisplayName`/`CSharpKeyword`) that matches the generator's `ToDisplayString()` ordinal — handles namespaces, nesting (`.`), generic args (`<>`, namespace-qualified), arrays, `Nullable`, and special-type keywords. New test `Build_OrdersGenericInheritedInterfaces_ByDisplayName_NotFullName` uses `IGen` vs `IGen` (a case where `FullName` and display-name ordering diverge) and fails on the old code. | +| 5 | A | A | Low | Correctness/Consistency | `TappedRentedNexusDuplexPipe` (`TappedNexusDuplexPipe.cs:44-70`) is dead code — nothing constructs it since R4 made locally-rented pipes pass through unwrapped. Misleading (implies rented pipes are tapped). | Deleted the dead `TappedRentedNexusDuplexPipe` type from `TappedNexusDuplexPipe.cs`; corrected the now-inaccurate `TestPipeFactory` summary/see-cref (only remote pipes are wrapped; local pipes pass through). | +| 6 | D | D | Low | Correctness | Raw `Task.Run` fire-and-forget inside a handler (after the interceptor's `finally` decrements `inDispatch`) is undetectable by `QuiesceAsync`. Documented known residual (workflow Decisions 2026-05-06); no suite test relies on it; not a regression. | Not actioned (D). Documented known residual (workflow Decisions 2026-05-06); no suite test depends on `QuiesceAsync` settling a `Task.Run`-spawned side effect. | +| 7 | D | D | Low | Test | Several streaming/showcase tests are wall-clock-bound (`WaitAsync(2–5s)`; cancellation test's 10ms-CT-vs-200ms-delay ≈190ms headroom is the tightest). Acknowledged v1 limitation — fake time deferred to #75. | Not actioned (D). Wall-clock timing is an acknowledged v1 limitation; deterministic fake-time control is tracked by follow-up issue #75. | +| 8 | A | C | Low | Consistency | Packed `NexNet.Testing` NuGet inherits the repo-root `README.md` (core NexNet docs) via `NexNet.NuGet.targets`; it never mentions the harness API. Pack succeeds — content-accuracy nit for the published package. | Added a harness-specific `src/NexNet.Testing/README.md` (overview, install, quickstart, API table, notes). Parameterized the shared `GenerateNuGetReadme` target with a `NuGetReadmeSource` property (defaults to the repo-root README) and set it to the project README in `NexNet.Testing.csproj`. Verified by packing: `NexNet.Testing.0.15.0.nupkg` now ships the harness README (`# NexNet.Testing`); core `NexNet.0.15.0.nupkg` still ships the root README (no regression). | + +Rec totals: 3 A, 0 B, 2 C, 3 D. **Applied (user override C→A): 5 A, 0 B, 0 C, 3 D** — findings 4 and 8 promoted C→A; all A items fixed in-branch this REMEDIATE round; D items (#2, #6, #7) documented/tracked, no action. + +## Plan Compliance + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| `TapChannel` (Phase 11) never implemented; the public type it was meant to feed — `ChannelRecording` — ships with no harness producer. Referenced only by itself + its own unit test. R4 deferred `TapChannel`, but the orphaned public `ChannelRecording` remained shipped. | Low | Public packaged type with no way to obtain a populated instance from the harness. Promoting/demoting public surface post-ship is a one-way door — either wire `TapChannel` or make it internal until demand exists. | +| Quiescence documented as "three per-session counters," but every session shares one `QuiescenceCounters` keyed at id `0` (`NexusTestHost.cs:95`, `:205`). Functionally sound (nets to zero in aggregate; inline comment acknowledges it), but diverges from the per-session model the plan/Decisions describe; `GetCountersFor(long sessionId)` is effectively unused. | Low | Design drift. No correctness impact today, but a future change relying on per-session counter isolation (e.g., per-client quiescence) would build on a false premise. | + +## Correctness + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| `MethodIdMap.Build` does not filter `[NexusMethod(Ignore = true)]` methods, but the generator's `NexusDataExtractor` does (`allMethodsList.Where(m => !m.MethodAttribute.Ignore)` at line 176, before `AssignMethodIds`). Ignored-method interfaces would get runtime ids that no longer line up with the generator's; assertions against any method declared after an ignored one resolve to the wrong id (or `Array.Empty` → false "not received"). Demo uses no ignored methods → latent. | Med | The map is load-bearing for the whole assertion API; a silent off-by-one on ignored-method interfaces makes `AssertReceived`/`AssertNotReceived` quietly match the wrong method. Not exercised by any test. | +| `MethodIdMap` orders inherited interfaces by `Type.FullName` ordinal (`:71`) vs the generator's `ToDisplayString()` ordinal (`NexusDataExtractor.cs:154`). For a generic inherited interface these keys differ, shifting per-interface method grouping. XML doc acknowledges the metadata-token caveat but not this divergence. Demo declares all methods directly → untested. | Low | Latent generator-parity gap on inherited-generic-interface RPC shapes; same silently-wrong-id failure mode. | +| Raw `Task.Run` after the interceptor's `finally` decrements `inDispatch` is undetectable by `QuiesceAsync`. Documented residual (workflow Decisions 2026-05-06); not a regression; no suite test depends on it. | Low | Documented limitation, not a defect. Tests leaning on `QuiesceAsync` to settle a `Task.Run`-spawned side effect would be racy; none do. | +| `TappedRentedNexusDuplexPipe` (`TappedNexusDuplexPipe.cs:44-70`) is dead code — nothing constructs it after R4 (`TestPipeFactory.WrapLocal` returns `inner`); references limited to its own definition + a ``. | Low | Unused internal type carrying a `TappingPipeReader`/`Writer` pair. Harmless but misleading — implies rented pipes are tapped. | + +## Security + +**No concerns.** `ServerNexusBase.Authenticate` pattern-matches `Config is ServerConfig` before reading `OnAuthenticateOverride` — safe (no mock sets `Config = null`; override is `internal`). `TestAuthenticationStore.OverrideDelegate` rejects null/empty/unknown tokens → handshake auth-disconnect, covered by `OnAuthenticateOverrideTests` + `Connect_WithoutIdentity_IsRejectedAtHandshake`. `InternalsVisibleTo` correctly scoped (core grants `NexNet.Testing` only; the `NexNet.Testing.Tests`-on-core grant stays removed per R9). `OnAuthorize` demo policy (AND-semantics, case-sensitive ordinal) documented inline. No present defects. + +## Test Quality + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| `ChannelRecording` is public but tested only in isolation (`ChannelRecordingTests.cs` uses `new` + `RecordItem` directly). No test — and no harness code — exercises it against a real tapped channel because `TapChannel` was never built. Its intended use has zero coverage. | Low | Public API no end-to-end test validates in its real role. Couples to the Plan-Compliance orphan finding. | +| `MethodIdMap` parity pinned only for the two demo interfaces (all methods direct, no `Ignore`). The two correctness gaps above (ignored methods; generic inherited interfaces) have no regression test. | Low | The map is the assertion API's foundation; its two known divergence modes from the generator are untested. | +| Several tests gate on real-time `WaitAsync(2–5s)`; `ListActiveEditors_CancelledToken_PropagatesAndThrows` races a 10ms client CT against a 200ms server delay (~190ms headroom). Reliable here; exception loosened to `OperationCanceledException` (Phase-14 fix). Wall-clock-bound — fake time deferred to #75. | Low | Potential rare flakiness under extreme CI load; acknowledged design limitation. | + +## Codebase Consistency + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| `NexNet.Testing.csproj` is `IsPackable=true` and inherits `README.md` via `NexNet.props`, sourced from repo-root `README.md` through `NexNet.NuGet.targets`. The published `NexNet.Testing` package ships the core-NexNet README, which doesn't describe the harness. Pack succeeds — content nit, not a build break. | Low | A consumer browsing `NexNet.Testing` on NuGet sees core docs with no mention of the harness they installed it for. | +| `TappedRentedNexusDuplexPipe` (see Correctness) and `ChannelRecording` (see Plan/Test) are shipped but unreferenced by any producing path. Otherwise the package follows repo conventions cleanly — file-scoped namespaces, `.ConfigureAwait(false)` on every await (analyzer-confirmed), `internal` hooks with scoped `InternalsVisibleTo`, XML docs on public surface, `Interlocked`/`Volatile` counter discipline. | Low | Two orphaned types are the only consistency drift. | + +## Integration / Breaking Changes + +**No concerns.** Core production-code changes are all additive + internal: `ConfigBase.InvocationInterceptor`/`PipeFactory`, `ServerConfig.OnAuthenticateOverride`, `ISessionInvocationStateManager.PendingInvocationCount`, `INexusSession.PipeFactory`, `NexusServer.SessionManagerInternal`. The interface additions are technically breaking only for *external implementers* of those interfaces — but both are `internal`, so no out-of-graph consumer can implement them; mocks updated. Null interceptor/factory on every non-harness path → identical dispatch. The 4-type-param `CreateAsync` shape is intentional + documented (R12). New deps minimal/trustworthy (`NexNet` project ref + build-time `ConfigureAwaitChecker.Analyzer`); no new runtime third-party packages. + +## Regression check (prior fixes intact?) + +All load-bearing prior fixes survived the Session-7 rebase on master (TimeProvider PR #76): +- **bytesInTransit wiring (R1, finding 5):** INTACT — `CountingPipeWriter.Advance` (+) / `CountingPipeReader` on `AdvanceTo` (−), both halves; `QuiesceAsync_AwaitsRealActivityEndToEnd` passes. +- **PendingInvocationCount probe (R1, findings 6/7):** INTACT — registered via `InternalOnSessionSetup` on both configs; object-keyed dictionary. +- **Multi-client shared-nexus guard (R2, finding 4):** INTACT — `_seenClientNexuses` ReferenceEqualityComparer guard; both tests pass. +- **MethodIdMap declaration-order fix (R6, findings 8/10/11/25):** INTACT — `DeclaredOnly`, no `.Distinct()`, pinned layout + determinism test. (The two new parity gaps above are pre-existing latent edges, not rebase regressions.) +- **Local-pipe pass-through (R4/R8 finding 17):** INTACT — `WrapLocal` returns `inner`; single `CompleteTask` continuation in `Track`. +- **OnAuthenticateOverride fallback (P4/R11 finding 39):** INTACT — covered on Tcp+InProcess. +- **InternalsVisibleTo scope (R9 finding 20, R11/D finding 40):** INTACT — csproj grants `NexNet.Testing` only. +- **NexusServer rebase merge (Session 7):** INTACT — master's `ITimer? _watchdogTimer` + branch's `SessionManagerInternal` both present. +- **Predicate-exception surfacing (R9/21), monotonic WaitFor clock (R8/18), diagnostic-includes-args (R7/34), Rendezvous.Unregister (R11/31), cached OverrideDelegate (R11/32), DisposeAsync logging (R11/33):** all INTACT. + +No prior fix was broken by the rebase or by Phase 14. + +--- + +# Review: Phase 14 (showcase rewrite, PR #77) + +## Classifications + +22 findings after consolidating 5 overlap pairs (P4↔T1 EditorJoined; C3↔T3 CT-exception-type; C4↔S4 Whisper silent no-op; P2↔T4 WaitFor async-wait; C5↔T7 [SetUp] convention). + +| # | Class | Rec | Sev | Section | Finding | Action Taken | +|---|-------|-----|-----|---------|---------|--------------| +| 1 | A | A | Low | Plan | `MethodIdMap.cs:21` doc-comment references `GeneratorParity_DemoInterfaces` (renamed in 14b) | Updated comment to point at the post-rename test names (`GeneratorParity_EditorServerInterface_AssignsExpectedIds` + matching client variant). | +| 2 | A | A | Low | Plan+Test | Test 11 `WaitFor_DraftSaved_ResolvesWhenInvoked` runs producer synchronously, doesn't exercise async-wait path | Producer now runs in `Task.Run` with a 100ms `Task.Delay` before calling SaveDraft, so the waiter actually traverses the async-arrival path; added `await producer` for clean teardown. | +| 3 | A | A | Low | Plan+Test | Test 13 `MixedTraffic` missing `times:` + negative assertion on Whisper non-recipients | Added explicit `times: 1` on all 4 callback assertions; added `AssertNotReceived` on alice and carol for WhisperReceived. | +| 4 | A | A | Low | Plan+Test | `EditorJoined` callback (fired by `OpenDocument`) has no end-to-end assertion | New test `OpenDocument_NotifiesOthersExceptCaller` — alice + bob open first; carol joins; asserts alice + bob each receive `EditorJoined("carol")` once; carol doesn't see her own join. | +| 5 | A | A | Low | Correctness | `await Task.Delay(200, ct)` in `ListActiveEditors` slows every caller — guard with `if (cancellationToken.CanBeCanceled)` | Wrapped the delay in `if (cancellationToken.CanBeCanceled)` — happy-path callers using `CancellationToken.None` no longer pay the 200ms tax. | +| 6 | A | A | Low | Correctness | Stale "50ms" comment in `EditorAppShowcaseTests.cs:154` (actual delay is 200ms) | Comment rewritten to reflect the 200ms delay and the `CanBeCanceled` guard. | +| 7 | A | A | Low | Correctness+Test | CT-propagation test pins `TaskCanceledException` (loosen to `OperationCanceledException`) | Switched to `Assert.That(..., Throws.InstanceOf())` so the test admits any OCE subtype (TaskCanceledException today, anything else the framework might return tomorrow). | +| 8 | D | D | Low | Correctness+Sec | `Whisper(long, string)` silent no-op on bad targetSessionId — test-only pattern risk, not a bug | Not actioned (D). Test-only nexus; framework's silent no-op is documented; not a regression risk. | +| 9 | A | A | Low | Correctness+Test | `AssertionTests` lacks `[SetUp] ResetAll()`; convention split across 5 test files | Added `[SetUp]` calling `EditorServerNexus.ResetAll()` to both `AssertionTests` and `NexusTestHostTests`. All 5 test fixtures now follow the same convention. | +| 10 | B | B | Low | Correctness | Static state would race under `[Parallelizable]` — add comment on `ResetAll()` documenting the constraint | XML comment on `ResetAll()` now explicitly calls out the sequential-fixture-execution assumption and what enabling `[assembly: Parallelizable(ParallelScope.Fixtures)]` would race. | +| 11 | D | D | Low | Correctness | `LeaveDocument` broadcasts before removing from `ActiveEditors` — intentional ordering, no race in current tests | Not actioned (D). Ordering is correct for the current GroupExceptCaller broadcast semantics; no test exercises the eventual-consistency window. | +| 12 | D | D | Low | Correctness | `Nexus.Context.Id` post-connect availability is undocumented contract — works today | Not actioned (D). All harness sessions Context is populated synchronously inside ConnectAsAsync's await flow before the call returns. | +| 13 | A | A | Low | Correctness | `MixedTraffic` pipe created without `await using` — leak risk on failure path | Changed `var pipe` → `await using var pipe`. Disposal now happens deterministically on test exit even when WhenAll throws. | +| 14 | A | A | Low | Correctness | `StreamEdits_AllOpsCollected` channel never disposed | Changed `var channel` → `await using var channel`. | +| 15 | D | D | — | Correctness | `EditorServerNexus.SaveDraft` doesn't `GetOrAdd` doc state — verified non-issue | Not actioned (D). SaveDraft doesn't read or mutate doc state; group broadcast is the only side effect. | +| 16 | D | D | Low | Security | Marker-only `[NexusAuthorize()]` path unexercised — no demo method uses it | Not actioned (D). No demo method uses the marker-only form; the comment added under #17 documents the implicit behavior. | +| 17 | A | A | Low | Security | `OnAuthorize` AND-semantics (all roles required) not documented in showcase | Multi-line comment above OnAuthorize override now explicitly calls out the AND-semantics, the empty-`requiredPermissions` behavior, and that this is a per-app policy choice (not a framework default). | +| 18 | B | B | Low | Security | No test for `Identity == null` (anonymous) calling a gated method | New test `Connect_WithoutIdentity_IsRejectedAtHandshake` — pins the discovery that the framework rejects null-identity sessions at the handshake (TransportException "Authentication failed"), so the OnAuthorize null-identity guard is actually dead code. Test body comment explains the path. | +| 19 | D | D | Low | Test | `AssertionTests.SetupAsync` doesn't quiesce after ConnectAsAsync — minor convention drift | Not actioned (D). Tests in AssertionTests always quiesce before assertions; pre-assertion quiesce drains handshake state too. | +| 20 | A | A | Low | Consistency | Redundant `using NexNet;` in `EditorAppNexus.cs:9` (namespace nests inside NexNet.*) | Removed `using NexNet;` directive. | +| 21 | A | A | Low | Consistency | `ResetAll()` doesn't comment on PingCount being instance-scoped | XML comment above `ResetAll()` now explains why PingCount is omitted (instance field per-test, not static). | +| 22 | D | D | Low | Consistency | `SaveDraft` doesn't `GetOrAdd` (overlap with #15) — verified non-issue | Not actioned (D). Duplicate of #15. | + +Rec totals: 13 A, 2 B, 0 C, 7 D. + +## Plan Compliance + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| `MethodIdMap.cs:21` doc-comment references `MethodIdMapTests.GeneratorParity_DemoInterfaces` as the parity-regression test name — that test was renamed in Phase 14b to `GeneratorParity_EditorServerInterface_AssignsExpectedIds` / `GeneratorParity_EditorClientInterface_AssignsExpectedIds`. The stale reference points readers to a test that no longer exists. | Low | Documentation rot from the Demo→Editor rename. Anyone navigating from the source comment will not find the named test. | +| Plan §test 11 (`WaitFor_DraftSaved_ResolvesWhenInvoked`) specifies "Bob calls `SaveDraft` in background. Alice `WaitFor`s the callback before `QuiesceAsync`." Implementation (`EditorAppShowcaseTests.cs:210-223`) calls `await bob.Server.SaveDraft` in the foreground after starting the waiter. The waiter still races correctly because it's registered before the call returns, but the test does not actually demonstrate "WaitFor unblocks while the call is in flight" — only "WaitFor unblocks after a synchronous await." The plan's stronger intent (truly async producer) is not asserted. | Low | The test passes but doesn't pin the property the plan called out (background producer); a future refactor that makes WaitFor synchronous-only would not be caught. | +| Plan §test 13 (`MixedTraffic`) calls for "3 clients fire SaveDraft + UploadAttachment + StreamEdits + Whisper concurrently. `QuiesceAsync` returns; all per-client assertions pass with **the expected counts**." Implementation (`EditorAppShowcaseTests.cs:238-283`) only asserts presence of three specific callbacks (`DraftSaved` on alice/bob/carol, `WhisperReceived` on bob) plus the server-side state. There is no `times:` count assertion and no negative assertion on non-recipients (e.g., no check that carol does NOT receive `WhisperReceived`). The test could pass even if a single SaveDraft broadcast duplicated and Whisper fired twice. | Low | The "QuiesceAsync waits for everything" property is plausibly demonstrated, but the "expected counts" language in the plan is not enforced. | +| `EditorJoined` callback (fired from `OpenDocument`) is never asserted anywhere in the showcase or other test files. The plan's test table calls `EditorJoined(name)` out as the callback fired by `OpenDocument`, but the only end-to-end assertion on it is implicit (the `Groups_Introspection` test never verifies that other clients in the group received the join notification). | Low | Coverage gap — a regression that silently dropped the GroupExceptCaller broadcast in OpenDocument would not surface in the showcase. | + +## Correctness + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| `EditorServerNexus.cs:142` — `await Task.Delay(200, cancellationToken)` is unconditional on every `ListActiveEditors` call. This is test-only nexus code, but it pays the 200ms tax on every caller, not just the cancellation test. The `ListActiveEditors_ReturnsNames` test (line 134) and any future showcase that calls `ListActiveEditors` waits the full 200ms even though no cancellation is in play. Comments on the line explain why, but the design couples a single edge-case test to all other callers' latency. Worth flagging as a design coupling smell even though it's test-only code. | Low | Slows the full showcase by 200ms per ListActiveEditors call (currently 1, future tests may add more). A guard like `if (cancellationToken.CanBeCanceled) await Task.Delay(200, cancellationToken);` would isolate the cost to the cancellation test. | +| `ListActiveEditors_CancelledToken_PropagatesAndThrows` comment (`EditorAppShowcaseTests.cs:154`) says "`await Task.Delay(50, ct)` in ListActiveEditors" but the actual delay is 200ms. Stale comment from an earlier iteration. | Low | Reader confusion; minor doc-rot. | +| CT-propagation test timing margin: client CT fires at 10ms, server delay is 200ms → ~190ms headroom for the cancel signal to round-trip through the in-process router. Adequate for typical CI but tight on a heavily contended runner (GC pauses, slow ALC startup). The test asserts `TaskCanceledException` specifically rather than the broader `OperationCanceledException` base — any future change to the framework's cancellation surface (e.g., wrapping in `RemoteInvocationException`) would break this test even when behaviour is still correct. | Low | Potential CI flakiness under load; tight exception-type pinning. | +| `Whisper(long targetSessionId, string text)` does not validate `targetSessionId`. `Context.Clients.Client(badId).WhisperReceived(...)` resolves through `LocalInvocationRouter.InvokeClientAsync` (`LocalInvocationRouter.cs:62`) which silently returns `false` when `_context.Sessions.TryGetValue(badId, out _)` fails. Caller observes success. No test exercises a bad-id whisper. The test-only nexus is the right place to leave this as-is, but it's worth noting that any user who copies this pattern into production code inherits the silent-no-op behaviour. | Low | Test-only impact; pattern an end-user might mistakenly take as production-ready for spam-target validation. | +| Static cross-session state on `EditorServerNexus` (`Documents`, `ActiveEditors`, `LastUploadedBytes`, `LastCollectedItems`) is reset via `[SetUp]` (`EditorAppShowcaseTests.cs:24-25`, `ClientAssertionTests.cs:15-16`, `StreamingExtensionsTests.cs:16-21`). `AssertionTests.cs` does NOT have a `[SetUp]` clearing the statics. `AssertionTests` doesn't itself depend on the static state in its assertions, but `AssertReceived_MultiArg_AllMustMatch` calls `SaveDraft("design.md", "v1")` which mutates `ActiveEditors`/`Documents` (via `OpenDocument` no — wait, SaveDraft itself doesn't add to ActiveEditors, only OpenDocument does, and AssertionTests never calls OpenDocument). Verified: AssertionTests never touches the static dicts; safe. The inconsistency (some test classes reset, one does not) is a maintenance hazard if a future test added to `AssertionTests` starts using `OpenDocument`. | Low | Convention drift across test classes; latent risk for future test additions. | +| Static state is process-global across test fixtures. NUnit's default is sequential fixture execution (no `[Parallelizable]` attribute anywhere in the test project, verified by Grep). If a future change enables `[assembly: Parallelizable(ParallelScope.Fixtures)]`, the per-`[Test]` `ResetAll()` from one fixture would race with another fixture's `[Test]` body. The current 18-test fixture takes ~10-15s with the 200ms Task.Delay, which is the kind of duration that often motivates enabling parallelism. | Low | Fragile under parallelism enablement; worth a comment on `ResetAll()` documenting the constraint, or moving to a `Lock`-guarded snapshot. | +| `LeaveDocument` (`EditorAppNexus.cs:107-114`): broadcasts `EditorLeft` BEFORE removing the caller from the group. The `GroupExceptCaller` filter ensures the caller doesn't receive their own leave notification, so this ordering is correct for the broadcast semantics. But the static `ActiveEditors` registry is also mutated AFTER the broadcast — meaning a concurrent `ListActiveEditors` call between the broadcast and the registry-remove will still see the leaver. Not a bug for the current tests (no test exercises that race), but the ordering relative to user-observable state is undocumented. | Low | Ordering not pinned; future tests doing OpenDocument+LeaveDocument+ListActiveEditors interleaving could surface eventual-consistency surprises. | +| `Whisper_DeliveredToTargetOnly` (`EditorAppShowcaseTests.cs:79-95`) reads `bob.Nexus.Context.Id` at line 87 to obtain Bob's session id. `Nexus.Context` is set lazily by the framework on first invocation; in the current test, `bob` is created via `ConnectAsAsync` which awaits `ConnectAsync()` — but `Context` is populated by the framework when the server's session-setup callback fires, which may be after the connect Task completes. If `Context` is null at line 87 (e.g., on a slower transport setup), the test NREs. Worth verifying that ConnectAsAsync guarantees Context is set before returning — the test passes today, so it does, but the contract isn't documented. | Low | Test relies on an undocumented post-connect Context-availability invariant. | +| `MixedTraffic` test (`EditorAppShowcaseTests.cs:258`) creates a pipe via `var pipe = alice.CreatePipe();` without `await using`. Manual disposal at line 273 runs only after `WhenAll` succeeds; if `WhenAll` throws (any of 6 tasks faults), the pipe leaks. Same for the channel created at line 262 — never disposed. The tests' 2-5s `WaitAsync` timeouts mean a hang surfaces as a TimeoutException, leaving both resources orphaned. | Low | Resource-leak surface in failure paths only; passing tests are fine. | +| `StreamEdits_AllOpsCollected` (`EditorAppShowcaseTests.cs:197`): the channel returned by `Nexus.Context.CreateChannel()` is never disposed in the test. The underlying rented pipe should be released by the framework once the channel writer/reader complete, but the test doesn't make this explicit. | Low | Same as above — resource hygiene in tests. | +| `EditorServerNexus` doesn't `Documents.GetOrAdd` on `SaveDraft`/`Whisper`/`ListActiveEditors`. Only `OpenDocument`/`UploadAttachment`/`StreamEdits` populate the dictionary. If a test calls `SaveDraft("undocumented.md", "...")` without first calling `OpenDocument`, the broadcast goes to an empty group (no callbacks fire) and the test would still pass any positive-receive assertion — wait, no, the assertion would FAIL because nobody received. This is correct behavior. No issue here. | — | (Verified non-issue, kept for the record.) | + +## Security + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| `OnAuthorize` override (`EditorAppNexus.cs:171-187`) returns `Unauthorized` whenever `context.Identity` is not a `TestIdentity`. For NexNet methods marked `[NexusAuthorize(...)]` with at least one permission, this correctly denies non-test identities. But the framework also supports `[NexusAuthorize()]` with zero permissions (a marker-only attribute). With the demo nexus, no method uses the zero-permission form, so the empty-`requiredPermissions` path through the for-loop is not exercised in OnAuthorize itself — only the Identity-not-TestIdentity guard runs. Worth a note: any future addition of a marker-only `[NexusAuthorize()]` method would still require a `TestIdentity` (any TestIdentity, any roles) to be Allowed. Tests pass non-TestIdentity in zero scenarios, so this isn't actively broken, but the behavior is implicit. | Low | Implicit auth behaviour for the marker-only case; not test-covered. | +| `TestIdentity` and `[NexusAuthorize]` together in the showcase model are an "all roles required" AND-semantics (the for-loop returns Unauthorized on the first missing role). An end-user reading the showcase as a pattern might assume the same is true of the framework's OnAuthorize call, but the AND-vs-OR semantics are entirely policy-defined inside the user's OnAuthorize body. The showcase doesn't explicitly call this out via a comment. | Low | Pattern adoption risk for users who copy the OnAuthorize body literally without understanding the policy choice. | +| `BroadcastSystemAnnouncement_AsNonAdmin_Throws` confirms the auth gate fires for a Write-only identity. There's no test for `Identity == null` (anonymous) attempting an `[NexusAuthorize]` method — the most permissive misuse, where a client connects without any identity and tries to call a gated method. The OnAuthorize override does return Unauthorized in that case (the `is not TestIdentity` guard catches null too), but it isn't asserted. | Low | Coverage gap on the anonymous-call-to-gated-method path. | +| `Whisper` accepts arbitrary `long targetSessionId` from any authenticated client. As noted in Correctness, the framework silently no-ops an unknown id. There's no per-session rate-limit or recipient-allowlist. In the test demo, that's by design — but if this pattern were lifted into production code, any authenticated user could enumerate session ids by observing whisper acks (though there are no acks here, just fire-and-forget). The showcase doesn't carry a "DO NOT COPY TO PROD" warning. | Low | Pattern adoption risk; demo doesn't explicitly disclaim production-readiness of the target-resolution path. | + +## Test Quality + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| `EditorJoined` callback (fired by `OpenDocument` to other group members via `GroupExceptCaller`) is never asserted in any test. Plan §test 2 is `LeaveDocument_NotifiesOthersExceptCaller`, but the corresponding `OpenDocument`-side broadcast has no symmetric test. Coverage gap: a regression that drops the `EditorJoined` send would not surface. | Med | Half of the join/leave broadcast surface is untested end-to-end. | +| `MixedTraffic_QuiesceAsync_WaitsForEverything` (`EditorAppShowcaseTests.cs:238-283`) asserts that DraftSaved arrived but not that it arrived exactly once. SaveDraft is called once; if a regression caused double-broadcasting, the test would still pass (`AssertReceived` defaults `times: 1` and there's no negative bound on extras — actually `AssertReceived(times: 1)` should fail on times=2). Let me re-check: yes, `AssertReceived` with default `times: 1` requires exactly 1. So a double-broadcast WOULD fail. Verified non-issue. However the test also does not assert `times: 5` on the StreamEdits ops or `Length=256` on the attachment beyond an equality check (`Is.EqualTo(attachment)`). The equality check captures both, so OK. Mostly the test is tighter than I initially thought; the only gap is the missing negative assertion on Whisper recipients (alice + carol should NOT receive WhisperReceived). | Low | Missing negative assertions on Whisper non-recipients in MixedTraffic. | +| `ListActiveEditors_CancelledToken_PropagatesAndThrows` (`EditorAppShowcaseTests.cs:151-165`) pins `TaskCanceledException` specifically. If the framework's cancellation surface ever wraps the underlying OCE (e.g., in a `RemoteInvocationException` or surfaces it as the more general `OperationCanceledException` directly), the test breaks even though behavior is correct. Loosening to `Assert.ThrowsAsync(...)` would still pin the cancellation semantic without overcommitting to the exact subtype. | Low | Tight exception-type pinning to a non-load-bearing subtype. | +| `WaitFor_DraftSaved_ResolvesWhenInvoked` (`EditorAppShowcaseTests.cs:210-223`) awaits `bob.Server.SaveDraft` synchronously in the same thread before awaiting the waiter. The waiter is started before the call, but in `InProcess` the dispatch is fast enough that the callback may already be in the recorder by the time `await waiter` is reached. The test doesn't differentiate "WaitFor unblocked because a match arrived" from "WaitFor returned immediately because the match was already recorded." A clearer assertion: start a `Task.Delay(100); SaveDraft(...)` in a background task before awaiting the waiter, so the test actually exercises the async wait path. | Low | Test passes via either code path; the load-bearing async-wait semantics aren't pinned. | +| `BroadcastSystemAnnouncement_AsAdmin_DeliversToAll` (`EditorAppShowcaseTests.cs:108-121`) tests with admin + alice + bob. There's no group-membership-based exclusion to check (All is unconditional), but the test doesn't assert that clients with no group memberships (alice/bob never call OpenDocument) still receive the All broadcast — implicitly, since they assert it does arrive, the test does verify this. Non-issue, just confirming. | — | (Verified.) | +| Tests use `await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2))` consistently in showcase tests — good. But `AssertionTests.SetupAsync` doesn't quiesce after `ConnectAsAsync`, and tests in `AssertionTests` rely on the first `host.QuiesceAsync` later in the test to settle handshake state too. Works today; minor inconsistency with the showcase pattern that always quiesces after multi-client setup. | Low | Convention drift between AssertionTests and the showcase. | +| `[SetUp]` lives only on `EditorAppShowcaseTests` and `ClientAssertionTests` and a partial cleanup on `StreamingExtensionsTests`. `NexusTestHostTests` and `AssertionTests` do not reset static state. As noted in Correctness, those tests don't currently depend on the static state, but the convention split is a maintenance hazard. A `[OneTimeSetUp]` calling `EditorServerNexus.ResetAll()` per fixture, or a static `[SetUpFixture]` at assembly level, would unify the pattern. | Low | Maintenance convention spread across 5 files; easy to forget. | + +## Codebase Consistency + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| `EditorAppNexus.cs:13` declares `namespace NexNet.Testing.Tests` and uses `using NexNet;`, `using NexNet.Invocation;`, `using NexNet.Pipes;`. The redundant `namespace NexNet.Testing.Tests` declaration adds noise — file-scoped namespaces match the rest of the test project (verified: `AssertionTests.cs:6`, `EditorAppShowcaseTests.cs:10` all use file-scoped namespaces). EditorAppNexus.cs uses the same file-scoped form. Consistent. | — | (Verified consistent.) | +| `EditorServerNexus.ClientProxy` and `EditorClientNexus.ServerProxy` are referenced as nested types in test type parameters — matches the existing demo nexus pattern. `EditorServerNexus : ServerNexusBase` uses the generator-emitted nested proxy correctly. The interface partial declarations (`internal partial interface IEditorServerNexus` at `EditorAppNexus.cs:202`) match the pattern from the deleted `HarnessSampleNexus.cs`. | — | (Verified consistent.) | +| `EditOp` is `public partial record struct EditOp` (`EditorAppNexus.cs:23`) — `public` while the surrounding nexus types are `internal`. Necessary because MemoryPack requires the type to be at least as accessible as any context that serialises it (the generator emits `[MemoryPackable]` partial extensions which need access). Worth verifying this matches the project's convention; the deleted `HarnessSampleNexus.cs` similarly had public record types alongside internal nexus types (verified via the diff). | — | (Verified consistent with deleted file's pattern.) | +| `DocPermission` enum at `EditorAppNexus.cs:15-20` is `public` — same rationale as `EditOp`, the generator needs access. Consistent with `[NexusAuthorize]` requiring `TPermission` to be accessible from the generated guard code. | — | (Verified consistent.) | +| `EditorAppNexus.cs` uses `using NexNet;` at line 9 — this collides with the file's own namespace `NexNet.Testing.Tests`. The C# compiler resolves this, but the explicit `using NexNet;` is redundant since the file's namespace already nests inside `NexNet.*`. Minor noise. | Low | Stylistic only; harmless. | +| `EditorServerNexus.ResetAll()` resets `Documents`, `ActiveEditors`, `LastUploadedBytes`, `LastCollectedItems` but does NOT reset `PingCount` (an instance field, not static, so it's per-instance — correct, no need to reset on the static class). Worth a comment in `ResetAll` clarifying the scope. | Low | Implicit instance vs. static field distinction. | +| `OpenDocument` uses `Documents.GetOrAdd(docId, _ => new DocState());` to seed `Documents` but `SaveDraft` does not. Inconsistent — if a test calls `SaveDraft` without `OpenDocument` first, the doc state is never initialised. Not a bug for current tests (SaveDraft doesn't read doc state), but inconsistent. | Low | Documentation/comment would clarify the SaveDraft-doesn't-touch-state intent. | +| `UploadAttachment` and `StreamEdits` use `Documents.GetOrAdd(docId, _ => new DocState());` — symmetric with OpenDocument. Good. | — | (Verified consistent.) | + +## Integration / Breaking Changes + +| Finding | Severity | Why It Matters | +|---------|----------|----------------| +| Phase 14 is test-project-only churn. The only production-code change is the addition of `await Task.Delay(200, ct)` to the test-only `EditorServerNexus.ListActiveEditors` method, which lives in `src/NexNet.Testing.Tests/EditorAppNexus.cs` — NOT in `src/NexNet/` or `src/NexNet.Testing/`. No consumer-facing API was changed, removed, or added. The harness API (`NexusTestHost`, `NexusTestClient`, `Assertions`, etc.) is unchanged in Phase 14. | — | (No concerns.) | +| MethodId shift: deleting `JoinGroup`/`BroadcastToGroup` shifts the remaining demo-interface IDs. These methods existed only on the (renamed and now-replaced) `IDemoServerNexus`. No production consumer or integration test referenced those methods. `MethodIdMapTests` was updated in 14b to pin the new 14-method layout. | — | (No concerns.) | +| The `[InternalsVisibleTo("NexNet.Testing.Tests")]` grant on `NexNet.Testing.csproj` was untouched in Phase 14. EditorAppNexus.cs uses public types (`Nexus`, `ServerNexusBase`, `INexusDuplexPipe`, `INexusDuplexChannel`, `NexusAuthorize`, `AuthorizeResult`, `ServerSessionContext<>`) — no new internals reach is required by the Phase 14 additions. | — | (No concerns.) | +| `EditorAppNexus.cs` is `internal` (the classes are internal, types referenced from outside the assembly are not). The `Documents` / `ActiveEditors` dictionaries are `public static readonly` on an internal class — so reachable only within the test assembly. Acceptable. | — | (No concerns.) | + +--- + +# Review: add-nexnet-testing (Phases 1-13, prior review) + +## Classifications + +| # | Class | Rec | Sev | Section | Finding | Action Taken | +|---|-------|-----|-----|---------|---------|--------------| +| 1 | A | C | Med | Plan Compliance | Phase 11 scope reduction: streaming-helper extensions skipped | R4: added `PipeUploadAsync`/`PipeDownloadAsync`/`ChannelPublishAsync`/`ChannelCollectAsync` in `StreamingExtensions`. `TapChannel` deferred (would need a typed-channel tap that bypasses the existing pipe Unsafe.As limitation surfaced here — see TestPipeFactory.WrapLocal note). 4 new tests cover both upload/download directions and typed publish/collect. | +| 2 | A | C | Med | Plan Compliance | Phase 13 scope reduction: group introspection + showcase tests deferred | R3: added `host.Groups[name].Members/Count` via `GroupView`/`GroupIntrospector` backed by the live `IGroupRegistry`; new `HarnessShowcaseTests` covers empty group, membership reflection across 3 clients, and broadcast delivery only to members. | +| 3 | A | C | Med | Plan Compliance | `NexusTestClient` lacks `.AssertReceived` / `.AssertNotReceived` / `.WaitFor` (plan §12) | R5: per-client recorder + interceptor constructed in `ConnectAsAsync`; assertion methods delegate to extracted `RecorderAssertions` helper shared with the host-side variant. 5 new tests cover positive/negative match, timeout, count mismatch, broadcast-isolation between clients. | +| 4 | A | C | High | Correctness | Multi-client `ConnectAsAsync` hang on second call against same host | R2: root cause was user passing a shared nexus instance via factory; harness now detects duplicate returns and throws a clear `InvalidOperationException`. Factory args are now optional with `new T()` default. | +| 5 | A | A | High | Correctness | `bytesInTransit` quiescence counter is never incremented | R1: wired via `CountingPipeWriter`/`CountingPipeReader` in `InProcessTransport` | +| 6 | A | A | High | Correctness | `PendingInvocationCount` probe is never registered with the tracker | R1: registered per-session via `InternalOnSessionSetup` on server + client configs | +| 7 | A | A | High | Correctness | `RegisterPendingInvocationProbe` / `UnregisterPendingInvocationProbe` are dead code on the host path | R1: probe API replaced with object-keyed dictionary; wired from session-setup callback | +| 8 | B | B | Med | Correctness | `MethodIdMap` declaration-order heuristic with no determinism test | R6: added `MethodIdMapTests` with parity assertions for IDemoServerNexus / IDemoClientNexus + a determinism check (build N times, compare). | +| 9 | B | B | Med | Correctness | `ArgumentDeserializer` parameter-type filter is namespace-prefix based and brittle | R7: replaced namespace-prefix check with explicit `FullName` matches against the generator's exclusion set (INexusDuplexPipe, IRentedNexusDuplexPipe, INexusDuplexUnmanagedChannel`1, INexusDuplexChannel`1, CancellationToken). Future generator additions now require an explicit update in both places. | +| 10 | B | B | Med | Correctness | `MethodIdMap` lookup uses `Type.GetMethods(Public\|Instance)` — interface `GetMethods()` returns only declared methods | R6: switched to `BindingFlags.DeclaredOnly` for the direct interface, then iterate inherited interfaces explicitly. | +| 11 | B | B | Med | Correctness | `MethodIdMap.CollectDeclaredMethods` uses `.Distinct()` on `MethodInfo` which can yield non-deterministic order across inherited interfaces | R6: removed `Distinct()`; inherited interfaces are sorted alphabetically by FullName (matches the generator's `OrderBy(i => i.ToDisplayString())`). | +| 12 | B | B | Low | Correctness | `QuiescenceTracker.SignalChange` swap is not synchronized with the lock-protected `Sample()` | R1: signal+counters now snapshot under the same lock in `SampleAndSnapshotSignal` | +| 13 | B | B | Low | Correctness | `QuiescenceTracker.QuiesceAsync` may busy-loop on `CancellationToken` cancellation | R1: added `ThrowIfCancellationRequested` at top of loop | +| 14 | B | B | Low | Correctness | `TappingPipeReader.AdvanceTo` records the slice from the buffer start, not from prior consumed watermark | R8: behavior verified correct — `result.Buffer` after AdvanceTo starts at the new watermark, so `Slice(0, consumed)` already captures bytes from prior watermark to current. Noted as intended in code comments. | +| 15 | B | B | Low | Correctness | `TappingPipeReader` discards `_hasLastBuffer` after each `AdvanceTo`, losing recording for un-advanced subsequent `TryRead`s | R8: behavior verified — peek-then-TryRead-again pattern doesn't lose data since the second TryRead's StoreBuffer re-stages the buffer; subsequent AdvanceTo records from the new buffer start (same position as the old start since no AdvanceTo intervened). | +| 16 | B | B | Med | Correctness | `TappedNexusDuplexPipe` constructor schedules a continuation that may fire before the wrapper is published | R8: removed both wrapper continuations; `TestPipeFactory.Track` now owns the single `CompleteTask` continuation that drives both `RecordCompletion` and `ClosePipe`. Eliminates the duplicate callback path and the constructor-publish race window. | +| 17 | B | B | Med | Correctness | `TestPipeFactory.WrapLocal` keys nothing by id (local pipes start at Id=0) and never receives the rebound id | R8: no longer applicable after R4 — local pipes are passed through unwrapped (the `Unsafe.As` cast in the proxy made wrapping unsafe). Id-keying is now intentionally only populated by `WrapRemote` where the id is stable at wrap-time. | +| 18 | B | B | Med | Correctness | `Assertions.WaitFor` uses `DateTime.UtcNow` rather than a monotonic clock | R8: switched `RecorderAssertions.WaitFor` to `Environment.TickCount64` (matches `ServerNexusBase` convention; resistant to DST/NTP wall-clock jumps). | +| 19 | D | D | Low | Correctness | `TappingPipeWriter.GetSpan` re-routes through `GetMemory` (perf only on tapped pipes) | | +| 20 | A | A | Med | Security | `InternalsVisibleTo("NexNet.Testing.Tests")` on core `NexNet.csproj` exposes core internals to a third package | R9: removed from `NexNet.csproj`. Tests now go through `NexNet.Testing` for any internal access; `NexNet.Testing.csproj` still grants its own internals to the test project. | +| 21 | B | B | Med | Security | `ArgMatcher.PredicateMatcher.DynamicInvoke` swallows all exceptions silently — test-internal predicate failures become false matches | R9: predicate-thrown exceptions are now surfaced as a `NexusAssertionException` whose Message names the inner exception type + message, and whose InnerException preserves the original cause. Also added `(message, inner)` constructor to `NexusAssertionException` (which also addresses finding 37). | +| 22 | B | B | Low | Security | `TestAuthenticationStore` tokens never expire and persist for host lifetime | R9: documented the design explicitly in XML docs (test-only; lifetime-bound by host disposal); added `Clear()` so long-lived hosts can bound memory between scenarios. | +| 23 | A | A | Med | Test Quality | `Type.InProcess` was added only to a representative subset of test classes, far short of "full matrix" expansion the plan promised | R10: added `[TestCase(Type.InProcess)]` to pipes (4 files), collections (3 files), groups, cancellation (2 files), and invalid-invocations. Remote-endpoint and rate-limiting tests intentionally left out (they assert real socket addresses). | +| 24 | A | A | Med | Test Quality | Hook tests (`InvocationInterceptorTests`, `PipeFactoryHookTests`, `OnAuthenticateOverrideTests`) only exercise `Type.Tcp` | R10: added `[TestCase(Type.InProcess)]` to all three hook test classes; the hook itself is transport-independent so these now validate the wiring on at least one non-socket transport too. | +| 25 | A | A | Med | Test Quality | No tests for the `MethodIdMap` determinism / generator-parity claim | R6: `MethodIdMapTests` pins expected ids for the demo interfaces against source declaration order; explicit-id precedence + slot-skipping is exercised via a synthetic `IExplicitIdSample` interface. | +| 26 | B | B | Med | Test Quality | `QuiescenceTracker` tests use raw counter handles in isolation, not under contention from the actual interceptor/factory wired in production paths | R1: added `QuiesceAsync_AwaitsRealActivityEndToEnd` driving real Ping+Notify load through the full pipeline | +| 27 | B | B | Med | Test Quality | `AssertionTests` only exercise single-arg `Ping(int)`; no multi-arg or string-arg coverage at the end-to-end level | R7: added 3 new tests covering string-arg matching (Notify), multi-arg matching with wildcards (BroadcastToGroup), and the mismatch-diagnostic-includes-args contract. | +| 28 | B | B | Low | Test Quality | `ExpressionParserTests.NonCallExpression_Throws` constructs a synthetic AST instead of a real misuse case | R10: replaced the synthetic AST with a real `n => n.IntValue` property-access lambda — the kind of mistake a user is most likely to write. Still throws `ArgumentException`. | +| 29 | D | D | Low | Test Quality | Pipe-side recording captures consumed bytes only — intentional and documented | | +| 30 | B | B | Med | Codebase Consistency | New `NexNet.Testing` code is missing `.ConfigureAwait(false)` in places despite the `ConfigureAwaitChecker` package being referenced | R11: audited every `await` in NexNet.Testing — all already have `.ConfigureAwait(false)` and the ConfigureAwaitChecker analyzer build-clean confirms it. Marked verified. | +| 31 | B | B | Low | Codebase Consistency | `InProcessRendezvous.Unregister` uses awkward `KeyValuePair`-based remove instead of `TryRemove` overload | R11: rewritten with explicit `TryGetValue` + `ReferenceEquals` + `TryRemove(key, out _)`; semantics preserved (only removes when the entry still maps to the same listener). | +| 32 | B | B | Low | Codebase Consistency | `TestAuthenticationStore.OverrideDelegate` allocates a new delegate instance on every read | R11: delegate now cached in a readonly field set in the constructor; property returns the cached instance. | +| 33 | B | B | Low | Codebase Consistency | `NexusTestHost.DisposeAsync` swallows every exception from `StopAsync` | R11: catches and logs via `_serverConfig.Logger?.LogError` instead of swallowing; teardown failures now surface in test output. | +| 34 | B | B | Low | Codebase Consistency | `Assertions.BuildMismatchMessage` doesn't include recorded args (only method ids), undermining the plan's "diagnostic must include actual recorded arg values" requirement | R7: mismatch diagnostic now deserializes the recorded args and renders `Notify("alpha")` rather than just `#1`. Falls back to `` if deserialization itself throws. | +| 35 | B | B | Med | Integration / Breaking Changes | `NexusTestHost.CreateAsync` has 4 type parameters with no inference path — a hard-to-revise v1 API | R12: shape accepted (the nested ClientProxy / ServerProxy types can't be inferred from the outer nexuses without generator changes). Added explicit `` docs + a `` example block on the XML so users see the boilerplate up front. A 2-type-parameter overload could be added later non-breakingly if the generator surfaces the proxies as type-level associations. | +| 36 | B | B | Low | Integration / Breaking Changes | `NexusTestHost.ServerRecorder` and `Tracker`/`AuthStore` are `internal` but the host class is `public sealed` — extensibility is precluded | R12: exposed `host.RecordedServerInvocationCount` (public int, no internal types leak) as the v1 read-side handle. Keeping `ServerRecorder`/`Tracker`/`AuthStore` internal for v1 — they expose internal types (`InvocationRecord` etc.); promoting them is a one-way door we defer until the assertion API proves insufficient. | +| 37 | B | B | Low | Integration / Breaking Changes | `NexusAssertionException` has no `(message, innerException)` constructor and is `sealed` | R9: added `(message, inner)` constructor — used by R9's PredicateMatcher to wrap user-predicate exceptions. The class stays sealed (intentional v1 surface). | +| 38 | B | B | Low | Integration / Breaking Changes | `InProcessRendezvous` is a process-static singleton, complicating parallel test isolation across AppDomains/AssemblyLoadContexts | R12: documented the AppDomain/ALC scope explicitly in the type-level XML; noted that `CreateAsync` already generates GUID-suffixed endpoints per host so concurrent same-domain runs don't collide. ALC-split test runners get separate registries by design. | +| 39 | B | B | Med | Integration / Breaking Changes | `ServerNexusBase.Authenticate` now uses a pattern-cast on `Config` that silently no-ops if config is non-`ServerConfig` — the existing contract for tests/mocks is broken | R11: verified by code-search that no integration test mocks `Config = null!`; the existing `OnAuthenticateOverrideTests` already cover both the override path and the fallback to `OnAuthenticate`, and R10's matrix expansion runs those tests on InProcess too. Marked covered by existing regression. | +| 40 | D | B | Low | Integration / Breaking Changes | `InternalsVisibleTo("NexNet.Testing")` is split between `NexNet.csproj` and an assembly attribute the plan called for; project chose csproj only | R11 attempted source-attribute migration; user feedback (2026-05-26) preferred the csproj convention for consistency with the existing grants. Reverted to a single `` line in `NexNet.csproj`. Reclassified D (kept current convention). | diff --git a/_sessions/add-nexnet-testing/timeprovider-issue-body.md b/_sessions/add-nexnet-testing/timeprovider-issue-body.md new file mode 100644 index 00000000..f69ce5ae --- /dev/null +++ b/_sessions/add-nexnet-testing/timeprovider-issue-body.md @@ -0,0 +1,66 @@ +## Description + +Adopt `System.TimeProvider` (.NET 8+) across NexNet core so time-dependent behavior — ping interval, reconnection backoff, authorization cache TTL, rate-limiter sliding window, handshake timeout, disconnect delay, mutex timeouts, etc. — can be deterministically controlled in tests via `Microsoft.Extensions.TimeProvider.Testing.FakeTimeProvider`. + +This was scoped out of the `add-nexnet-testing` workflow (which adds an in-process transport, the test harness, and recorder/quiescence infrastructure). The harness ships without time control as a known limitation; this issue tracks the follow-up. + +## Location + +Surface to add: + +- `ConfigBase.Time { get; init; } = TimeProvider.System;` + +Refactor sites (verified by grep on master at the time of writing — exact list to be re-validated when work begins): + +- `src/NexNet/NexusClient.cs` — `_pingTimer = new Timer(...)`, reconnect `Task.Delay(delay.Value)`, timeout calc. +- `src/NexNet/NexusClientPool.cs` — `_healthCheckTimer`, `_lastUsed = DateTime.UtcNow`, idle-timeout comparisons. +- `src/NexNet/NexusServer.cs` — `_watchdogTimer`, timeout calc. +- `src/NexNet/RateLimiting/ConnectionRateLimiter.cs` — `_cleanupTimer`, sliding-window timestamps. +- `src/NexNet/Internals/NexusSession.cs` — `Task.Delay(_config.DisconnectDelay)`. +- `src/NexNet/Internals/NexusSession.Receiving.cs` — handshake timeout `Task.Delay`, `LastReceived = Environment.TickCount64`. +- `src/NexNet/Internals/Threading/MutexSlim.cs` — `GetTime()`, `Task.Delay(localTimeout)`. +- `src/NexNet/Pipes/NexusPipeReader.cs` — `Task.Delay` retry loop. +- `src/NexNet/Invocation/ServerNexusBase.cs` — auth cache TTL (currently uses `TickCountOverride?.Invoke() ?? Environment.TickCount64`; consolidate into `TimeProvider`, remove the ad-hoc override). +- `src/NexNet/Invocation/SessionInvocationStateManager.cs` — `state.Created = Environment.TickCount64`. +- `src/NexNet/Collections/Lists/NexusListRelay.cs` — `Task.Delay(TimeSpan.FromMilliseconds(500))`. +- `src/NexNet/Transports/SocketTransport.cs` — connection timeout `Task.Delay`. +- `src/NexNet/Transports/WebSocket/WebSocketPipe.cs` — close timeout `Task.Delay`. +- `src/NexNet/Logging/CoreLogger.cs` — `Stopwatch.StartNew()`. + +## Diagnostics + +Without `TimeProvider` injection, end-user tests of time-sensitive logic (auth cache expiry, reconnection behavior, ping-driven heartbeat, rate-limiter sliding windows) must use real `Task.Delay` and are inherently slow / flaky. + +The existing `TickCountOverride` on `ServerNexusBase` is a one-off seam that already proves the value of injection but only addresses authorization cache testing. + +## What Has Been Tried + +Nothing yet — this is the tracking issue for the deferred work. + +## Gathered Information + +- .NET 8+ ships `TimeProvider` in BCL. +- `Microsoft.Extensions.TimeProvider.Testing` provides `FakeTimeProvider` with `Advance(TimeSpan)` for deterministic tests. +- `TimeProvider.CreateTimer(...)` substitutes for `new Timer(...)`. +- `TimeProvider.GetTimestamp()` substitutes for `Stopwatch.GetTimestamp()`. +- `TimeProvider.GetUtcNow()` substitutes for `DateTime.UtcNow`. +- Cancellation-aware `Task.Delay` with TimeProvider: `Task.Delay(delay, timeProvider, ct)` (single-overload available). +- `Environment.TickCount64` has no direct `TimeProvider` equivalent; sites using it for elapsed-duration comparisons should switch to `GetTimestamp()` + `GetElapsedTime(start)`. + +## Suggested Approach + +1. Add `TimeProvider Time { get; init; } = TimeProvider.System;` to `ConfigBase`. +2. Plumb `Configs.Time` through to every refactor site listed above. +3. Replace `new Timer(...)` with `Configs.Time.CreateTimer(...)`. +4. Replace `DateTime.UtcNow` with `Configs.Time.GetUtcNow()`. +5. Replace `Environment.TickCount64` (used for elapsed-duration math) with `Configs.Time.GetTimestamp()` + `Configs.Time.GetElapsedTime(start)`. +6. Replace `Task.Delay(timeSpan, ct)` with `Task.Delay(timeSpan, Configs.Time, ct)`. +7. Remove the ad-hoc `TickCountOverride` on `ServerNexusBase`; tests should use `FakeTimeProvider` instead. +8. Surface `TimeProvider` on `NexusTestHost.Options` so harness users can pass `FakeTimeProvider`. +9. Add integration tests that exercise auth-cache TTL expiry, reconnection backoff, and ping behavior under `FakeTimeProvider`. + +Phase atomically — refactor one subsystem at a time (auth cache → ping → reconnect → rate limiter → mutex → infrastructural timers) so each commit is independently verifiable. + +## Related + +- Deferred from workflow `add-nexnet-testing` (PR pending). diff --git a/_sessions/add-nexnet-testing/workflow.md b/_sessions/add-nexnet-testing/workflow.md new file mode 100644 index 00000000..a1dc795b --- /dev/null +++ b/_sessions/add-nexnet-testing/workflow.md @@ -0,0 +1,206 @@ +# Workflow: add-nexnet-testing + +## Config +platform: github +remote: https://github.com/Dtronix/NexNet.git +base-branch: master + +## State +phase: REMEDIATE +status: active +issue: discussion +pr: 77 +session: 8 +phases-total: 14 +phases-complete: 14 + +## Problem Statement + +Add an in-memory transport to NexNet core (`MemoryTransport` / `MemoryTransportListener` under `src/NexNet/Transports/Memory/`) and a new `NexNet.Testing` package providing a test harness on top of it. The harness lets end users (developers building apps on NexNet) test their own nexus implementations — server methods, client callbacks, authorization rules, broadcasts, group routing, pipes, and channels — without standing up sockets, ports, TLS, or fake auth providers. + +Three optional internal hooks are added to `NexusSessionConfigurations` and default to `null` pass-through, so production hot paths remain zero-cost: + +- `IInvocationInterceptor` — wraps invocation dispatch (used by harness for quiescence counters and the invocation recorder). +- `IPipeFactory` — produces pipe instances at creation time (used by harness to wrap pipes with `TappingPipeReader` / `TappingPipeWriter`). +- `IChannelFactory` — produces channel reader/writer pairs (used by harness to wrap channels with item-recording wrappers). + +Public end-user surface (single namespace `NexNet.Testing`): + +- `NexusTestHost.Create(opts)` — entry point. +- `NexusTestHost` — `ConnectAsync`, `ConnectAsAsync(roles)`, `QuiesceAsync`, `Groups`, `ServerNexus`, plus `PipeUpload` / `PipeDownload` / `ChannelCollect` / `ChannelPublish` / `TapNextPipe` / `TapChannel` extensions. +- `NexusTestClient` — per-client handle: `.Server` (proxy), `.Nexus`, `.SessionId`, `.AssertReceived(expr)`, `.AssertNotReceived(expr)`, `.WaitFor(expr)`. +- `Arg.Any()`, `Arg.Is(predicate)` — expression matchers. +- `TestIdentity.Of(name, roles)` — fake principal helper. +- `PipeRecording`, `ChannelRecording` — observation surfaces with `WaitForBytesAsync` / `WaitForCountAsync`. + +Quiescence is the load-bearing primitive that makes negative assertions (`AssertNotReceived`) meaningful: a `QuiesceAsync` that tracks three per-session counters (`bytesInTransit`, `inDispatch`, `pendingResults`) plus an `activePipes` lifecycle counter for pipes whose handler returned but whose stream is still open. + +## Test baseline (pre-change) + +- `dotnet test src/NexNet.Generator.Tests -c Release` → 149/149 passing. +- `dotnet test src/NexNet.IntegrationTests -c Release` → 2628/2628 passing. +- No pre-existing failures. + +## Decisions + +- 2026-05-06 — **Scope is bundled.** Ship `MemoryTransport` and `NexNet.Testing` together in one workflow (per user). Note: branch named `add-nexnet-testing` even though scope includes the transport. +- 2026-05-06 — **`MemoryTransport` lives in `NexNet` core, not in `NexNet.Testing`.** Reason: in-process RPC is a legitimate non-test use case (monolith with in-process modules); putting it in a Testing package would force production code to take a Testing dependency. It also lets the existing integration test suite validate it as just another transport. *(REVISED 2026-05-06: see Revisions section.)* +- 2026-05-06 — **Three core hooks (`IInvocationInterceptor` / `IPipeFactory` / `IChannelFactory`) are `internal`** with `InternalsVisibleTo("NexNet.Testing")` rather than `public`. Reason: only the harness needs them in v1; promoting them to public is a one-way door. Defer until external demand exists. +- 2026-05-06 — **`NexNet.Testing` is test-framework agnostic.** `AssertReceived` throws `NexusAssertionException` — NUnit/xUnit/MSTest surface that as a failure with no integration package needed. +- 2026-05-06 — **Quiescence model: three counters + pipe-lifecycle counter.** `bytesInTransit` (write-side increment, reader-AdvanceTo decrement), `inDispatch` (interceptor entry/exit), `pendingResults` (existing `RegisteredInvocationState` registry count), `activePipes` (incremented when a pipe is opened, decremented when it completes — covers the case where the invocation that opened the pipe has returned but the stream is still in use). `QuiesceAsync` waits for all four to be zero, with an `await Task.Yield()` re-check to drain synchronously-scheduled continuations. Documented residual: pure `Task.Run` fire-and-forget inside a handler is undetectable. +- 2026-05-06 — **Pipe tap records consumed bytes, not visible bytes.** `TappingPipeReader` records on `AdvanceTo`, capturing only the slice the user actually committed past — matches "what the handler saw." +- 2026-05-06 — **Recorder API uses LINQ expressions** (`AssertReceived(n => n.M(arg, Arg.Any()))`). Method ID lookup via the same `TypeHasher` machinery the generator uses; arg matching via `ExpressionVisitor`. +- 2026-05-06 — **Rollout is staged.** Step 1 ships `MemoryTransport` only and validates it by adding `Type.Memory` to existing `[TestCase]` matrices. Step 2 adds the hooks (pure refactor, no behavior change). Step 3 ships `NexNet.Testing`. Steps will be reflected as plan phases. +- 2026-05-22 — **Plan approved.** User approved `plan.md` as drafted. IMPLEMENT begins with Phase 1. +- 2026-05-27 — **Showcase rewrite (Phase 14).** Replace the `DemoServerNexus` / `JoinGroup` / `BroadcastToGroup` scaffolding with a realistic `EditorServerNexus` document-editor domain that exercises every harness feature via natural business methods. Reason: PR sample showed harness ergonomics in the worst light by forcing users to write passthrough methods just to test broadcasts; real NexNet code drives typed callbacks via `Context.Clients.Group(...)` inside business methods, and the showcase should reflect that. Folded into PR #77 (not split into follow-up). +- 2026-05-27 — **Demo domain: document editor.** `EditorServerNexus` methods: `OpenDocument(docId)`, `LeaveDocument(docId)`, `SaveDraft(docId, content)`, `Whisper(targetId, text)`, `BroadcastSystemAnnouncement(msg)` (admin-only), `ListActiveEditors(docId, ct)`, `UploadAttachment(docId, INexusDuplexPipe)`, `StreamEdits(INexusDuplexChannel)`. Client callbacks: `DraftSaved`, `EditorJoined`, `EditorLeft`, `WhisperReceived`, `SystemAnnouncement`. +- 2026-05-27 — **Full authorization story included.** `DocPermission { Read, Write, Admin }` enum, `[NexusAuthorize(Admin)]` on `BroadcastSystemAnnouncement`, `OnAuthorize` override consults `context.Identity as TestIdentity` and checks `IsInRole(((DocPermission)p).ToString())` for each required permission. **Role matching is case-sensitive ordinal**, role-name string equals enum-member name. +- 2026-05-27 — **Cross-session document state: static dictionary on `EditorServerNexus`.** `ConcurrentDictionary` static field. Tests clear at top of `[Test]` (matches the existing `LastUploadedBytes`/`PingCount` pattern in `DemoServerNexus`). +- 2026-05-27 — **`ListActiveEditors` returns `string[]` of display names.** Reads naturally in assertions and validates identity flow implicitly. +- 2026-05-27 — **`EditOp` is a `record struct(int Position, string Inserted)` over `INexusDuplexChannel`** (managed channel; the unmanaged variant doesn't apply because the struct contains `string`). MemoryPack-attributed. +- 2026-05-27 — **Drop two superseded HarnessShowcaseTests.** `Groups_ReflectMembershipAfterJoin` and `GroupBroadcast_DeliversToMembers` are covered by the new editor-app scenarios. `Groups_EmptyGroup_HasNoMembers` survives, renamed into the new showcase file. +- 2026-05-27 — **No new harness API.** The fix is the demo + showcase, not new surface. `host.Clients`-style direct broadcast and `client.JoinGroupAsync`-style direct membership are explicitly NOT added — they would conflict with the "drive real business methods, observe callbacks" pattern that this rewrite is built around. +- 2026-05-27 — **Phase 14 plan approved (Session 7).** User approved `plan.md` Phase 14 addendum as drafted. IMPLEMENT begins with sub-phase 14a (build `EditorAppNexus.cs`). + +### Revisions after source verification (2026-05-06) +- **Hook location:** Two nullable fields added to the `NexusSessionConfigurations` readonly struct (per user choice). Authoritative install point is on `ConfigBase` (so users set hooks once and all sessions inherit); the struct's session-construction site copies them in, giving `NexusSession` direct field access. +- **Hooks reduced to two, not three.** Channels do not need a core factory in v1 — harness convenience helpers (`ChannelCollect` / `ChannelPublish` / `TapChannel`) own channel creation and return tap-wrapped instances. Pipe-layer byte tap still observes any user-instantiated channels. +- **`IInvocationInterceptor` is "outer" only.** Wraps `nexus.InvokeMethod(message)` in `NexusSession.Receiving.cs` (around line 682). Has access to `InvocationMessage` (method ID + serialized arg bytes). Sufficient for quiescence; for assertion arg matching, harness lazily MemoryPack-deserializes args using a per-interface `methodId → ITuple<...>` map built via reflection over the user's `IServerNexus` / `IClientNexus` interfaces. NO generator changes required. +- **Auth model: dual.** New nullable `Func?, ValueTask>? OnAuthenticateOverride` added to `ServerConfig` (or `ConfigBase`-server side). When set, `ServerNexusBase.Authenticate` consults it before falling back to `OnAuthenticate`. Test harness installs an override mapping fake tokens (issued via `TestIdentity.Of(...)`) to `IIdentity` instances. Users testing their own auth leave override unset. +- **`TypeHasher` reuse not needed.** Recorder/assertion API resolves expressions to `MethodInfo`; matching is on method-name+signature against entries the interceptor records as `(InvocationMessage, MethodInfo)` pairs. The MethodInfo lookup is from a pre-built map (interface type → MethodId → MethodInfo) constructed via reflection at host startup. +- **`IPipeFactory` hook point:** `NexusPipeManager.RentPipe` (local) and `NexusPipeManager.RegisterPipe(byte)` (remote). Factory wraps the rented `RentedNexusDuplexPipe` / `NexusDuplexPipe` with a tap. On return-to-pool, the wrapper detaches and the underlying pipe resets normally. +- **Quiescence counter sources confirmed:** + - `bytesInTransit` — instrumented inside `MemoryTransport`'s pipe writer/reader pair (the harness owns this transport, so it can count framed messages without touching session code). + - `inDispatch` — incremented/decremented by the harness's `IInvocationInterceptor` around `InvokeMethod`. + - `pendingResults` — read from `SessionInvocationStateManager._invocationStates.Count`. Will need a small internal accessor (e.g., `internal int PendingInvocationCount => _invocationStates.Count;`) added to that class. + - `activePipes` — tracked by the harness's `IPipeFactory` (increment on rent, decrement on the pipe's `CompleteTask`). +- **`MemoryTransport` rendezvous design:** `MemoryServerConfig` registers the listener under a named endpoint (e.g., a string key) in a process-local registry. `MemoryClientConfig.OnConnectTransport()` looks up the listener and asks it to produce a paired transport. The pair share two `System.IO.Pipelines.Pipe` instances cross-wired (server-out → client-in, client-out → server-in). Each `MemoryTransport` instance implements `IDuplexPipe` directly over its assigned input/output pipe ends. +- **Group introspection:** `LocalGroupRegistry.GetLocalGroupMembers(string)` already returns `IEnumerable`; the harness exposes a thin wrapper `host.Groups[name].Members` that yields `SessionId` values. No core change. +- **Transport home revised: `InProcessTransport` lives in `NexNet.Testing`, not core.** Reason: production use cases are thin (modular monoliths, debug workflows) and not common in .NET. The integration-test-validation argument is preserved by having `NexNet.IntegrationTests` take a project reference to `NexNet.Testing` so the InProcess transport joins the existing transport `[TestCase]` matrix. +- **Type names:** `NexusTestHost` / `NexusTestHost` / `NexusTestClient` (matches existing `NexusServer`/`NexusClient`/`NexusBase` naming pattern). +- **Test framework integration:** Framework-agnostic. `AssertReceived` throws `NexusAssertionException`; NUnit/xUnit/MSTest surface it as a test failure with no integration package needed. +- **TimeProvider injection deferred to follow-up issue #75.** Verification revealed the actual scope (14 time refs + 5 timers + 9 `Task.Delay` calls + the existing `TickCountOverride` test seam on `ServerNexusBase`) is large enough to warrant its own focused workflow. Harness ships in v1 *without* time control as a known limitation. Tests of time-sensitive logic (auth cache TTL, reconnect, ping) remain real-time-bound until #75 lands. Issue body lives in `_sessions/add-nexnet-testing/timeprovider-issue-body.md` for reference. + +## Suspend State + +- **Phase:** PLAN — Phase 14 addendum (showcase rewrite) drafted in `plan.md`; awaiting user approval before transitioning to IMPLEMENT. +- **Sub-step:** End of PLAN. Plan.md was extended with a `Phase 14: Showcase rewrite — document-editor demo` section (5 sub-phases 14a–14e, full design including method table, OnAuthorize snippet, doc-state strategy, EditOp shape, 18-test showcase list, README sample, method-ID treatment for the JoinGroup/BroadcastToGroup deletion). All eight Phase 14 design decisions logged in `## Decisions` (2026-05-27 dates). +- **In progress:** Nothing actively executing. Code under `src/` not yet touched in Session 6. Tasks #4–#8 pending. +- **Immediate next step on resume:** + 1. If user approves the Phase 14 plan: set `phase: IMPLEMENT`, `status: active`, mark task #4 in_progress, begin sub-phase 14a (build `EditorAppNexus.cs`). + 2. If user wants changes to the plan: amend `plan.md` Phase 14 section accordingly, then re-prompt. +- **WIP commit:** This commit (`[WIP] Phase 14 plan: document-editor showcase rewrite`) IS the suspend point. No mid-code work to recover; the entire suspend state is in `plan.md` + `workflow.md`. +- **Test status (unchanged from previous suspend — no code touched in Session 6):** + - `NexNet.Generator.Tests`: 149/149. + - `NexNet.IntegrationTests`: 2825/2825. + - `NexNet.Testing.Tests`: 65/65. +- **Latest commit before this WIP:** `7ed0984`. +- **PR #77:** Still open with green CI. No change to remote branch state since previous suspend. +- **Phase 14 design highlights (carry forward):** + - Demo domain = collaborative document editor. New types: `EditorServerNexus` / `EditorClientNexus` / `IEditorServerNexus` / `IEditorClientNexus`. File: `EditorAppNexus.cs` (replaces `HarnessSampleNexus.cs`). + - Methods (renamed from Demo, with JoinGroup/BroadcastToGroup deleted, editor methods added): `Ping`, `Notify`, `Upload`, `Download`, `CollectStrings`, `PublishStrings`, `OpenDocument`, `LeaveDocument`, `SaveDraft`, `Whisper`, `BroadcastSystemAnnouncement` ([NexusAuthorize(Admin)]), `ListActiveEditors(docId, ct)→ValueTask`, `UploadAttachment(docId, pipe)`, `StreamEdits(channel)`. + - `DocPermission { Read, Write, Admin }`. `OnAuthorize` casts `context.Identity` to `TestIdentity`, checks `IsInRole(((DocPermission)p).ToString())` per required permission (case-sensitive ordinal). + - Cross-session state: static `ConcurrentDictionary` on `EditorServerNexus`, cleared at top of each `[Test]`. + - `EditOp` = `[MemoryPackable] record struct EditOp(int Position, string Inserted)` over `INexusDuplexChannel`. + - Method IDs after rewrite (deletion of JoinGroup/BroadcastToGroup + appended editor methods): 1=Ping, 2=Notify, 3=Upload, 4=Download, 5=CollectStrings, 6=PublishStrings, 7=OpenDocument, 8=LeaveDocument, 9=SaveDraft, 10=Whisper, 11=BroadcastSystemAnnouncement, 12=ListActiveEditors, 13=UploadAttachment, 14=StreamEdits. `MethodIdMapTests` will need a coherent update. + - HarnessShowcaseTests replaced by EditorAppShowcaseTests.cs (18 tests). `Groups_EmptyGroup_HasNoMembers` migrates; the other two are dropped as superseded. + - **No new harness API** — explicit decision. `host.Clients` / `client.JoinGroupAsync` were considered and rejected as conflicting with the "drive real business methods, observe callbacks" pattern. +- **Risks flagged for IMPLEMENT:** + - Auth integration exercised end-to-end for the first time (tests 4, 5, 6). May surface OnAuthorize / TestIdentity.Roles gaps. + - `ListActiveEditors` server-side impl: `Context` doesn't expose the group registry per-method; plan calls for a parallel `ConcurrentDictionary>` maintained by OpenDocument/LeaveDocument. Verify the approach against actual NexNet APIs before coding. + - MethodIdMapTests update is mechanical but a single-step coordinated change — easy to forget the shift. +- **Open task list:** + - #4 IMPLEMENT: build EditorServerNexus + EditorClientNexus (pending) + - #5 IMPLEMENT: rename Demo* → Editor* across dependent tests (pending) + - #6 IMPLEMENT: rewrite HarnessShowcaseTests as EditorAppShowcaseTests (pending) + - #7 IMPLEMENT: update README sample + pr-body.md (pending) + - #8 REVIEW + REMEDIATE: re-review showcase rewrite, rebase, push (pending) +- **All 12 remediation phases done:** + - **R1** (c9b3c2e) — Quiescence counters wired end-to-end (findings 5, 6, 7, 12, 13, 26). + - **R2** (8a49f16) — Detect shared-nexus factory misuse instead of hanging (finding 4). + - **R3** (f95a1a0) — Group introspection + broadcast showcase (finding 2). + - **R4** (5d3567d) — Streaming helpers + unwrap local pipes (finding 1). + - **R5** (7d660f4) — Per-client assertions on `NexusTestClient` (finding 3). + - **R6** (afa5d95) — MethodIdMap robustness + generator parity tests (findings 8, 10, 11, 25). + - **R7** (aec8169) — ArgumentDeserializer precise filter + diagnostic args (findings 9, 27, 34). + - **R8** (4c71168) — Tap recording fixes (findings 14, 15, 16, 17, 18). + - **R9** (604719c) — Security fixes (findings 20, 21, 22, 37). + - **R10** (f2061f2) — Test matrix expansion (findings 23, 24, 28). + - **R11** (3edaa79) — Consistency polish (findings 30, 31, 32, 33, 39, 40). + - **R12** (5b50310) — API ergonomics (findings 35, 36, 38). +- **Post-remediation polish committed after R12:** + - `d22caa6` — record PR #77 artifact. + - `ce26041` — revert R11 source-attribute `InternalsVisibleTo` move (kept csproj convention per user feedback); add CI test/pack steps for `NexNet.Testing`. + - `679c2b6` — CI: switch test runs to `--logger "console;verbosity=minimal"` to suppress per-test noise. + - `15ad8b0` — Per-test-project NUnit `ITestAction` progress reporter (writes to stderr so it survives the minimal logger). Cadence: every 100 tests or 15s for IntegrationTests; every 25 tests or 10s for Generator/Testing tests. + - `0a080a2` — bump `actions/checkout@v5`, `actions/setup-dotnet@v5`, `actions/upload-artifact@v5` to silence Node 20 deprecation. + - `7ed0984` — bump `actions/upload-artifact` to v7 (v5 still ran on Node 20). +- **PR body** lives at `_sessions/add-nexnet-testing/pr-body.md` and is the source for the GitHub PR description on #77. +- **Carryover guidance for resume:** + - If user picks FINALIZE: follow workflow REMEDIATE step 8 → FINALIZE. Pre-merge cleanup deletes `_sessions/add-nexnet-testing/` locally, pushes that deletion, then squash merges PR #77 with commit message `"Add NexNet.Testing harness + InProcessTransport + minimal core hooks (#77)"`. + - If user picks rebase: rebase on `origin/master`, re-run tests locally + watch CI, then re-prompt. + - If user wants more REVIEW: every review finding now has an "Action Taken" entry in `review.md` § Classifications; rerun the analysis agent for a fresh pass if needed. +- **Findings status (review.md § Classifications):** all 11 A's, 27 B's, and 2 D's have an Action Taken note. Finding 40 was reclassified D after the user-requested revert. + +### Context carried forward from earlier sessions (preserved for durability) + +### Context carried from Session 1 (preserved) +- User opted to defer TimeProvider integration to a follow-up issue (#75 created at https://github.com/Dtronix/NexNet/issues/75). +- User picked `NexusSessionConfigurations struct` for hook location even though Claude's recommendation was `ConfigBase`. Plan adapts: hooks are stored on `ConfigBase` (authoritative install point) and copied into the struct at session-construction time so the runtime read happens via the struct as the user requested. +- User picked `InProcessTransport` (revised from `MemoryTransport`) and chose `NexNet.Testing` as its home (revised from `NexNet` core). +- User-confirmed type names: `NexusTestHost`, `NexusTestClient`. Test framework: agnostic. Auth: dual mode. +- Method-ID derivation strategy in Phase 8: harness reflects on the *generated* `IInvocationMethodHash`-implementing classes (proxy/nexus types) to read actual method IDs at runtime — `TypeHasher` is Roslyn-only and not reusable from runtime test code. + +## Session Log +| # | Phase Start | Phase End | Summary | +|---|------------|-----------|---------| +| 1 | 2026-05-06 INTAKE | 2026-05-06 DESIGN | Bootstrapped workflow from discussion. Worktree + branch `add-nexnet-testing` created. Baseline tests green (149 generator + 2628 integration). | +| 1 | 2026-05-06 DESIGN | 2026-05-06 PLAN | Verified core surfaces against actual source via Explore agent. Recorded 7 design decisions and revised them after verification (transport home moved to `NexNet.Testing`; hooks reduced to two; outer-only interceptor; channels via helpers only; auth dual-mode). Created issue #75 to track deferred TimeProvider work. | +| 1 | 2026-05-06 PLAN | 2026-05-06 PLAN (suspended) | Drafted `plan.md` with 13 atomic phases, dependencies, file paths, and per-phase tests. User issued handoff before plan approval. Suspended for handoff to next session. | +| 2 | 2026-05-22 PLAN (resumed) | 2026-05-22 IMPLEMENT | Resumed suspended workflow. Plan approved by user. Phase 1 complete: `PendingInvocationCount` added to `ISessionInvocationStateManager` + concrete + test mock. All 149 generator + 2628 integration tests pass. Amended WIP commit `77553c3` into Phase 1 commit. | +| 2 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 2 complete: `IInvocationInterceptor` interface added; `InvocationInterceptor` property added to `ConfigBase` + `NexusSessionConfigurations` struct; copied through construction sites in `NexusServer`/`NexusClient`; wired into `InvocationTask` in `NexusSession.Receiving.cs`. `InternalsVisibleTo` added for `NexNet.Testing` + `NexNet.Testing.Tests`. 3 new interceptor tests pass. Full suite: 149 generator + 2631 integration green (one flaky UDS rate-limit test on first run, passed clean on rerun — unrelated to interceptor path). | +| 2 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 3 complete: `IPipeFactory` interface (WrapLocal for rented pipes, WrapRemote for registered pipes) added; same plumb-through pattern as Phase 2; hooked into `NexusPipeManager.RentPipe`/`RegisterPipe` after the inner pipe is registered in `_activePipes`. `INexusSession.PipeFactory` getter added. 2 new pipe-factory tests pass. Full suite: 2633 integration green. | +| 2 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 4 complete: `ServerConfig.OnAuthenticateOverride` (internal nullable Func) added; `ServerNexusBase.Authenticate` consults it before falling back to `OnAuthenticate`. 3 new tests cover no-override fallback, override-consulted-not-OnAuthenticate, and null-identity-disconnect. Full suite: 2636 integration green. | +| 2 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 5 complete: `NexNet.Testing` project created with `InProcessTransport` (paired `System.IO.Pipelines.Pipe`s cross-wired), `InProcessTransportListener` (Channel-based pending-connection queue), `InProcessServerConfig`/`InProcessClientConfig`, and a process-local `InProcessRendezvous` keyed on endpoint strings. `NexNet.Testing.Tests` project with 5 focused tests covers bidirectional exchange, ordering across 50 messages, close-completes-peer-reader, no-listener-throws, and duplicate-endpoint-throws. Both new projects added to `NexNet.slnx`. Full solution builds clean. | +| 2 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT (suspended) | End-of-session suspend. 5/13 phases done; commits `a4c42b3` (P1), `0f253c9` (P2), `4d05970` (P3), `3235bf1` (P4), `7e1d331` (P5). Working tree clean. Resume next session at Phase 6 (Type.InProcess matrix integration). | +| 3 | 2026-05-22 IMPLEMENT (resumed) | 2026-05-22 IMPLEMENT | User requested continuation through remaining phases. Resuming at Phase 6. Phase 6 complete: added `Type.InProcess` enum value, `_currentInProcessEndpoint` per-test state, server+client config branches in `BaseTests`; added `[TestCase(Type.InProcess)]` to 8 representative test classes (skipped `ReconnectsNotifiesReconnecting_Hosted` since it depends on ASP host start/stop). 106 new test cases. Full integration: 2742/2742 green. | +| 3 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 7 complete: recorder primitives in `NexNet.Testing` — `NexusAssertionException` (public), `Arg.Any()`/`Arg.Is(predicate)` (public sentinels), `ArgMatcher` (internal: wildcard/equality/predicate), `InvocationRecord`, `InvocationRecorder` (thread-safe append + snapshot + change-signal), `ExpressionParser` (resolves `Expression>` to MethodInfo + matchers using compiled-lambda fallback for constants and captured locals). `InternalsVisibleTo("NexNet.Testing.Tests")` added. 12 new tests cover the parser branches and recorder semantics; testing suite now 17/17 green. | +| 3 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 8 complete: `TestInvocationInterceptor` (IInvocationInterceptor impl that records into `InvocationRecorder` and tracks inDispatch on a `QuiescenceCounters`), `QuiescenceCounters` (atomic counter set per session), `QuiescenceTracker` (aggregates across sessions; uses observe-zero/yield/re-observe pattern; pull-style probe for PendingInvocationCount to avoid double-counting). 5 new quiescence tests; testing suite 22/22. **Method-ID resolution deferred to Phase 12**: the interceptor records with `method: null` for now, since the methodId → MethodInfo mapping requires runtime inspection of the user's generated proxy types and is only needed at assertion time. | +| 3 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 9 complete: `PipeRecording` (public; ConsumedBytes/WrittenBytes/IsCompleted/FaultedWith + WaitForBytesAsync/WaitForCompletionAsync), `TappingPipeReader` (records consumed slice on AdvanceTo), `TappingPipeWriter` (records on Advance), `TappedNexusDuplexPipe`/`TappedRentedNexusDuplexPipe` (wrappers delegating WriterCore/ReaderCore through), `TestPipeFactory` (IPipeFactory impl that wraps and brackets each pipe's lifetime with OpenPipe/ClosePipe on the counters). 5 new pipe-recording tests; testing suite 27/27. | +| 3 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 10 complete: `NexusTestHost.CreateAsync(...)` static entry point + the host class with `ServerRecorder` (internal until Phase 12 exposes it via assertions), `QuiesceAsync`, `ConnectAsync`/`ConnectAsAsync(IIdentity)`. `NexusTestClient` exposes `.Server`/`.Nexus`/`.Client`. `TestIdentity.Of(name, roles)` + `TestAuthenticationStore` (Guid-based token registry installed as the server's `OnAuthenticateOverride`). Demo nexus + 2 end-to-end tests; testing suite 29/29. **Known issue:** repeated `ConnectAsAsync` against the same host hangs for multi-client scenarios; deferred to a follow-up since single-client + many-invocations works. | +| 3 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 11 complete (minimal): `ChannelRecording` (public; Items/IsCompleted/FaultedWith + WaitForCountAsync/WaitForCompletionAsync) mirrors the byte-level PipeRecording for the typed channel layer. Streaming-helper extension methods (`PipeUpload`/`PipeDownload`/`ChannelCollect`/`ChannelPublish`/`TapChannel`) were skipped from v1 because they reduce to thin syntactic sugar over user code and would need to thread a session-aware pipe/channel factory — easier to ship the showcase tests with raw API and add helpers based on real demand. 4 new ChannelRecording tests; testing suite 33/33. | +| 3 | 2026-05-22 IMPLEMENT | 2026-05-22 IMPLEMENT | Phase 12 complete: server-side assertion API on `NexusTestHost` — `AssertReceived(expr, times)`, `AssertNotReceived(expr)`, `WaitFor(expr, timeout)`. Method-id resolution via `MethodIdMap` (mirrors generator's `AssignMethodIds`: declaration order, with `[NexusMethodAttribute(MethodId=N)]` honored). `ArgumentDeserializer` rebuilds the ValueTuple shape from MethodInfo and uses `MemoryPackSerializer.Deserialize(Type, span)` to recover per-arg values for matcher comparison; CancellationToken and pipe parameters are excluded from the serialized shape. 8 new assertion tests cover equality, wildcard, predicate, times-mismatch, AssertNotReceived present/absent, WaitFor success/timeout — all pass. Testing suite 41/41. | +| 3 | 2026-05-22 IMPLEMENT | 2026-05-22 REVIEW | Phase 13 closed at reduced scope. Group introspection (`host.Groups[name].Members`) and the multi-client showcase test suite (`GroupExceptCaller`, `AllExcept`, `Client(id)`, bulk 50-client broadcast) were deferred — both depend on the multi-client connect path that Phase 10 surfaced as broken. The existing AssertionTests + NexusTestHostTests already exercise the full harness end-to-end (host construction, auth, interceptor recording, quiescence, assertions) on the single-client path, so the testing-API contract is validated. Group introspection is a follow-up issue. Transitioning to REVIEW. | +| 4 | 2026-05-26 REVIEW (resumed) | 2026-05-26 REMEDIATE | Resumed at REVIEW. Verified baseline (149+2742+41 green). Delegated analysis pass → 40 findings. User override: C→A; all A+B+C addressed in-branch. Final: 11A/27B/0C/2D. | +| 4 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R1 complete (findings 5,6,7,12,13,26): wired `bytesInTransit` via `CountingPipeWriter`/`CountingPipeReader` plumbed through `InProcessServerConfig.Counters` → listener → transport; registered `PendingInvocationCount` probe per session via `InternalOnSessionSetup` on both server + client configs; replaced raw `Func` probe list with object-keyed dictionary; tightened `QuiescenceTracker` to atomically snapshot signal+counters and honor cancellation at top of loop. New e2e quiescence test (`QuiesceAsync_AwaitsRealActivityEndToEnd`) drives mixed Ping+Notify load. 149+2742+44 green. | +| 4 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R2 complete (finding 4): identified root cause of multi-client hang — user-supplied factory returning a shared nexus instance overwrites `_nexus.SessionContext` on the 2nd construction, corrupting handshake state. Wrapped both factories with `HashSet+ReferenceEqualityComparer` detection that throws `InvalidOperationException` on duplicate instance return. Made factory args optional (default `new T()`) and tightened CreateAsync XML docs. New `MultipleClients_CanConnectAgainstSameHost` (3 clients, 3 pings) and `SharedClientNexusInstance_ThrowsOnSecondConnect` (negative case). Testing suite 46/46. | +| 4 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R3 complete (finding 2): added `host.Groups[name].Members/Count` via new `GroupIntrospector`/`GroupView` types backed by `IServerSessionManager.Groups` (exposed via new internal `NexusServer.SessionManagerInternal`). Added `JoinGroup` and `BroadcastToGroup` to `DemoServerNexus` and a `HarnessShowcaseTests` test class (3 tests: empty group, membership across 3 clients, group broadcast delivery to members only). Also installed the shared `_interceptor` + `_pipeFactory` on the client config so client-side broadcast dispatch is tracked by quiescence. 49/49 testing green. | +| 4 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R4 complete (finding 1): added `PipeUploadAsync`/`PipeDownloadAsync`/`ChannelPublishAsync`/`ChannelCollectAsync` extension methods in `NexNet.Testing.Streaming.StreamingExtensions`. `client.CreatePipe()` shortcut added on `NexusTestClient`. Required removing the wrapper return from `TestPipeFactory.WrapLocal` — the framework's `ProxyInvocationBase.ProxyGetDuplexPipeInitialId` does an `Unsafe.As` cast, so an interface-only wrapper would read garbage memory. Locally-rented pipes are now passed through unwrapped (byte-level transport tap still feeds quiescence via `BytesInTransit`); remote-incoming pipes still wrap normally. 4 new streaming tests pass; 53/53 testing green. | +| 4 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE (suspended) | End-of-session suspend after R4. 4 of 12 remediation phases done; commits c9b3c2e (R1), 8a49f16 (R2), f95a1a0 (R3), 5d3567d (R4). Working tree clean. Resume next session at R5 (per-client assertions). | +| 5 | 2026-05-26 REMEDIATE (resumed) | 2026-05-26 REMEDIATE | Resumed at R5. R5 complete (finding 3): extracted assertion logic into `RecorderAssertions` shared by host and per-client API; `ConnectAsAsync` now creates a per-client recorder + interceptor; `NexusTestClient.AssertReceived/AssertNotReceived/WaitFor` delegate to the new engine. 5 new tests verify per-client isolation, timeout, mismatch. 58/58 testing green. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R6 complete (findings 8, 10, 11, 25): rewrote `MethodIdMap.CollectDeclaredMethods` to use `BindingFlags.DeclaredOnly` + alphabetically-sorted inherited interfaces (no `.Distinct()`), matching the generator's `OrderBy(i => i.ToDisplayString())` ordering. New `MethodIdMapTests` pins expected ids for demo interfaces + determinism + explicit-id precedence. 62/62 testing green. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R7 complete (findings 9, 27, 34): `ArgumentDeserializer.IsSerializableParameter` now matches the generator's exclusion list by exact FullName (no namespace-prefix fuzzy match); `RecorderAssertions` consumes that single source of truth. `BuildMismatchMessage` deserializes recent records' args and renders e.g. `Notify("alpha"); Notify("beta")` instead of `#1, #1`. Added 3 AssertionTests covering string-arg, multi-arg + wildcards, and the diagnostic-includes-args contract. 65/65 testing green. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R8 complete (findings 14, 15, 16, 17, 18): switched `RecorderAssertions.WaitFor` to `Environment.TickCount64` monotonic clock (finding 18). Removed redundant CompleteTask continuations from both `TappedNexusDuplexPipe` constructors; `TestPipeFactory.Track` is now the single owner that drives both `RecordCompletion` and `ClosePipe` from one continuation (finding 16). Findings 14, 15 verified as documented-correct edge cases (slice semantics already capture from prior watermark; peek-then-TryRead-again pattern doesn't lose data); added clarifying comments. Finding 17 marked no-longer-applicable since R4 made local pipes pass-through unwrapped. 65/65 testing green. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R9 complete (findings 20, 21, 22 + 37): removed `InternalsVisibleTo("NexNet.Testing.Tests")` from `NexNet.csproj` (tests now reach internals only through `NexNet.Testing`). `ArgMatcher.PredicateMatcher` now surfaces user-predicate exceptions as `NexusAssertionException` with inner-exception preserved, instead of silently reporting as a no-match. Added `(message, inner)` constructor to `NexusAssertionException` (incidentally addresses finding 37). `TestAuthenticationStore` gets a `Clear()` method + explicit XML docs documenting the test-only retention model. 65/65 testing green. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R10 complete (findings 23, 24, 28): added `[TestCase(Type.InProcess)]` to all three hook test classes + 4 pipe test files + 3 collections + groups + 2 cancellation + invalid-invocations (11 files total). Replaced ExpressionParser's synthetic-AST test with a real `n => n.IntValue` property-access case. Integration grew 2742 → 2825 (+83 InProcess cases), all green. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R11 complete (findings 30, 31, 32, 33, 39, 40): rewrote `InProcessRendezvous.Unregister` using `TryGetValue` + `ReferenceEquals` + `TryRemove(key, out _)`. Cached `TestAuthenticationStore.OverrideDelegate` as a constructor-set readonly field. `NexusTestHost.DisposeAsync` logs StopAsync exceptions via the configured Logger instead of swallowing. Moved `InternalsVisibleTo("NexNet.Testing")` from `NexNet.csproj` to source via `Properties/InternalsVisibleTo.cs`. Finding 30 (ConfigureAwait) verified clean by code-search + analyzer. Finding 39 verified theoretical — no integration mocks set Config=null! and `OnAuthenticateOverrideTests` (now covering InProcess too) exercise both paths. 65 testing + 2825 integration green. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | R12 complete (findings 35, 36, 38; 37 was done in R9): expanded `NexusTestHost.CreateAsync` XML docs with per-`typeparam` descriptions and a worked-example `` block so the 4-type-parameter shape is self-explanatory (finding 35 — the shape itself stays because the nested ClientProxy / ServerProxy types can't be inferred without generator changes). Added public `host.RecordedServerInvocationCount` as the v1 read-side handle (finding 36; keeping `ServerRecorder`/`Tracker`/`AuthStore` internal — promoting them would leak internal types and is a one-way door). Documented `InProcessRendezvous`'s AppDomain/ALC scope explicitly (finding 38). 65 testing + 2825 integration green. All 12 remediation phases complete. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | PR #77 created and pushed; first CI run green (4m27s). User feedback: revert R11's source-attribute migration of `InternalsVisibleTo("NexNet.Testing")` (finding 40 reclassified D — kept csproj convention) AND update `.github/workflows/dotnet.yml` to run `NexNet.Testing.Tests` and pack `NexNet.Testing`. Both applied: removed `src/NexNet/Properties/InternalsVisibleTo.cs`, restored the csproj `` entry, added two CI steps. 65 testing green locally. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE | CI test-output polish: switched all three test steps to `--logger "console;verbosity=minimal"` to suppress per-test pass lines (the 2825-case integration suite was dumping thousands of lines per CI run). Added an assembly-level NUnit `ITestAction` (`TestProgressReporterAttribute`) per test project that emits `[progress] : N ran (X passed, Y failed), Ts elapsed` on stderr at a fixed cadence (100 tests / 15s for IntegrationTests; 25 / 10s for the small suites). Stderr survives the minimal logger; TestContext.Progress does not at that verbosity. | +| 5 | 2026-05-26 REMEDIATE | 2026-05-26 REMEDIATE (suspended) | Bumped CI actions out of Node 20 deprecation: `actions/checkout@v5`, `actions/setup-dotnet@v5`, `actions/upload-artifact@v7`. Latest CI run (#26460964379) green in 4m27s with no deprecation annotation. User issued handoff — suspending at REMEDIATE step 8 awaiting finalize decision. Working tree clean; latest commit `7ed0984`. | +| 6 | 2026-05-27 REMEDIATE (resumed) | 2026-05-27 DESIGN | Resumed at REMEDIATE step 8. User raised ergonomics feedback on PR sample: the `JoinGroup`/`BroadcastToGroup` passthrough methods on DemoServerNexus don't reflect real NexNet usage (typed callbacks via `Context.Clients.Group(...)`). Agreed to expand demo to showcase all harness features via a realistic document-editor domain. Folding into PR #77 rather than splitting. Stepping back from REMEDIATE → DESIGN; phases-total bumped 13 → 14 (one new showcase rewrite phase). Prior REMEDIATE state (review.md classifications, R1–R12 actions, PR #77 body) preserved intact. Session 6 begins. | +| 6 | 2026-05-27 DESIGN | 2026-05-27 PLAN | DESIGN locked: document-editor domain, full auth story (NexusAuthorize + OnAuthorize matching TestIdentity.Roles case-sensitive ordinal against `((DocPermission)p).ToString()`), pipes + channels in unified showcase. Eight Phase 14 decisions recorded. Doc-state via static dict cleared per-test; ListActiveEditors returns string[] of names; EditOp is record struct over INexusDuplexChannel; drop two superseded showcase tests. No new harness API added (explicit decision). | +| 6 | 2026-05-27 PLAN | 2026-05-27 PLAN (suspended) | Phase 14 plan written into `plan.md`: 5 sub-phases (14a build EditorAppNexus.cs, 14b update dependent tests + MethodIdMapTests, 14c replace HarnessShowcaseTests with EditorAppShowcaseTests tests 1–10, 14d add tests 11–18, 14e update README + pr-body). Method-ID layout post-rewrite documented (deletion shifts Upload..PublishStrings; editor methods append at 7..14). 18 showcase tests enumerated. User issued handoff before plan approval. Suspended awaiting plan approval on resume. | +| 7 | 2026-05-27 PLAN (resumed) | 2026-05-27 PLAN | Resumed at end of PLAN. Baseline tests re-verified green (65/65 NexNet.Testing.Tests). Awaiting user approval of the Phase 14 plan before transitioning to IMPLEMENT. WIP commit `933423a` was already pushed, so 14a's commit will not amend it (safer to keep WIP as its own pushed commit than to force-push). | +| 7 | 2026-05-27 PLAN | 2026-05-27 IMPLEMENT | User approved Phase 14 plan as drafted. Decision logged. Phase 14a complete (`aaa67c7`): EditorAppNexus.cs added alongside HarnessSampleNexus.cs (additive only). Verified APIs against actual source via Explore agent + direct grep: server methods CAN take INexusDuplexChannel directly (agent missed; verified via ChannelSampleNexuses.cs). StreamEdits signature refined to `(string docId, INexusDuplexChannel)` so doc-state updates have a target. Build clean; 65/65 tests still pass. | +| 7 | 2026-05-27 IMPLEMENT | 2026-05-27 IMPLEMENT | Phase 14b complete: bulk-renamed Demo*→Editor* across NexusTestHostTests, StreamingExtensionsTests, AssertionTests. Rewrote broadcast tests in AssertionTests (multi-arg) + ClientAssertionTests (all 5) + HarnessShowcaseTests (3) to drive `OpenDocument` + `SaveDraft` (Write-gated) and assert on `DraftSaved` callbacks instead of the synthetic `JoinGroup`/`BroadcastToGroup`. Updated MethodIdMapTests to pin the new 14-method server interface layout (0=Ping..13=StreamEdits) and 5-method client interface (0=DraftSaved..4=SystemAnnouncement). Deleted HarnessSampleNexus.cs. Build clean; 65/65 tests still pass. Test identities now include `"Write"` role where SaveDraft is invoked. | +| 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. | +| 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. | +| 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. | +| 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. | +| 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` (#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). | +| 7 | 2026-05-27 REMEDIATE | 2026-05-27 REMEDIATE | Rebased on `origin/master` (master had moved with TimeProvider PR #76). One conflict in `src/NexNet/NexusServer.cs` at R3 (Group introspection): master added `private ITimer? _watchdogTimer` (TimeProvider integration); R3 had added `internal SessionManagerInternal` + kept `private readonly Timer _watchdogTimer`. Resolution kept both — the `SessionManagerInternal` accessor + the new `ITimer? _watchdogTimer` field. Remaining 41 commits replayed clean. Full post-rebase suite: 149 Generator + 2834 Integration + 82 Testing = 3065 tests, all green (one flaky integration failure on first run cleared on rerun). Force-pushed (`--force-with-lease`); branch tip is now `a040eff`. CI watch in progress. | +| 8 | 2026-05-28 REMEDIATE (resumed) | 2026-05-28 REMEDIATE | Resumed at REMEDIATE step 7 (CI verification). Confirmed CI green on current HEAD `c2ececd`: run #26537606515 (`Build, Pack & Publish`) concluded `success`; the prior run on `a040eff` (#26537546933) also succeeded. PR #77 is OPEN / MERGEABLE / mergeStateStatus CLEAN, base `master`, not draft. All 14 phases done, all review findings addressed. REMEDIATE step 8: prompting user for the finalize/merge decision. | +| 8 | 2026-05-28 REMEDIATE | 2026-05-28 REVIEW | At the FINALIZE gate the user chose **(C) Back to REVIEW** instead of merging. Stepped back from FINALIZE → REVIEW (`status: active`); session directory left intact (no cleanup). Prompting user for the re-examination scope (full re-analysis vs. targeted concern) before running the analysis pass. | +| 8 | 2026-05-28 REVIEW | 2026-05-28 REMEDIATE | User chose full branch re-analysis. Delegated a fresh 6-section analysis pass over `origin/master...HEAD` (44 commits, 93 files) to a general-purpose agent → 8 findings (1 Med, 7 Low) after consolidating cross-section overlaps; regression check confirmed all R1–R12 fixes survived the Session-7 rebase intact. Recs 3A/0B/2C/3D. **User override: C→A, implement all A/B/C now** → applied 5A/0B/0C/3D (findings 4, 8 promoted). New Session-8 review section + Classifications prepended to `review.md`; temp `_review-s8.md` folded in and removed. Transitioning to REMEDIATE to fix the 5 A items. | +| 8 | 2026-05-28 REMEDIATE | 2026-05-28 REMEDIATE | Implemented all 5 Session-8 A items. `e45fc78`: MethodIdMap generator parity — filter `[NexusMethod(Ignore=true)]` (#3, Med) + order inherited interfaces by a `ToDisplayString()`-equivalent display-name key instead of `Type.FullName` (#4); demote orphaned public `ChannelRecording` to internal (#1); delete dead `TappedRentedNexusDuplexPipe` + fix `TestPipeFactory` doc (#5); +2 regression tests (ignored-method filtering, generic-interface ordering). `75de5f1`: ship a NexNet.Testing-specific NuGet README (#8) via a parameterized `GenerateNuGetReadme` target (verified the packed harness README + that core `NexNet.nupkg` still ships the root README). D items #2/#6/#7 documented/tracked (no action). No rebase needed (origin/master unchanged at `d5f84ad`). Full suite green: 149 Generator + 2834 Integration + 84 Testing = 3067. `pr-body.md` + `review.md` Action-Taken updated. Pushing, updating PR #77, watching CI, then re-prompting finalize. | diff --git a/src/NexNet.Generator.Tests/Infrastructure/TestProgressReporter.cs b/src/NexNet.Generator.Tests/Infrastructure/TestProgressReporter.cs new file mode 100644 index 00000000..2e156249 --- /dev/null +++ b/src/NexNet.Generator.Tests/Infrastructure/TestProgressReporter.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading; +using NUnit.Framework; +using NUnit.Framework.Interfaces; + +// Apply at assembly scope so the action wraps every test in the assembly without each fixture +// having to opt in. +[assembly: NexNet.Generator.Tests.Infrastructure.TestProgressReporter] + +namespace NexNet.Generator.Tests.Infrastructure; + +/// +/// Assembly-level that emits one progress line per N tests OR every +/// T seconds — whichever fires first. See the integration-tests copy of this class for full +/// rationale; this file is a duplicate per-assembly because NUnit assembly-level actions are +/// not transitively imported across project references. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +internal sealed class TestProgressReporterAttribute : Attribute, ITestAction +{ + private const int EveryNTests = 25; + private static readonly TimeSpan EveryInterval = TimeSpan.FromSeconds(10); + + private static int _ran; + private static int _failed; + private static long _startTicks; + private static long _lastEmitTicks; + private static readonly object _gate = new(); + + public ActionTargets Targets => ActionTargets.Test; + + public void BeforeTest(ITest test) + { + if (_startTicks == 0) + { + var now = Environment.TickCount64; + if (Interlocked.CompareExchange(ref _startTicks, now, 0) == 0) + Volatile.Write(ref _lastEmitTicks, now); + } + } + + public void AfterTest(ITest test) + { + var ran = Interlocked.Increment(ref _ran); + var outcome = TestContext.CurrentContext.Result.Outcome.Status; + if (outcome == TestStatus.Failed) + Interlocked.Increment(ref _failed); + + var nowTicks = Environment.TickCount64; + var elapsedSinceLastEmit = nowTicks - Volatile.Read(ref _lastEmitTicks); + var hitTestCadence = (ran % EveryNTests) == 0; + var hitTimeCadence = elapsedSinceLastEmit >= (long)EveryInterval.TotalMilliseconds; + + if (!hitTestCadence && !hitTimeCadence) + return; + + lock (_gate) + { + if (Volatile.Read(ref _lastEmitTicks) > nowTicks - 100) + return; + + var elapsed = TimeSpan.FromMilliseconds(nowTicks - Volatile.Read(ref _startTicks)); + var assemblyName = typeof(TestProgressReporterAttribute).Assembly.GetName().Name; + var failed = Volatile.Read(ref _failed); + var passed = ran - failed; + // Stderr survives `console;verbosity=minimal`; TestContext.Progress does not. + Console.Error.WriteLine( + $"[progress] {assemblyName}: {ran} ran ({passed} passed, {failed} failed), {elapsed.TotalSeconds:F1}s elapsed"); + Volatile.Write(ref _lastEmitTicks, nowTicks); + } + } +} diff --git a/src/NexNet.IntegrationTests/BaseTests.cs b/src/NexNet.IntegrationTests/BaseTests.cs index 799f5171..1169485c 100644 --- a/src/NexNet.IntegrationTests/BaseTests.cs +++ b/src/NexNet.IntegrationTests/BaseTests.cs @@ -14,6 +14,7 @@ using NexNet.Invocation; using NexNet.Logging; using NexNet.Quic; +using NexNet.Testing.Transports.InProcess; using NexNet.Transports; using NexNet.Transports.HttpSocket; using NexNet.Transports.Uds; @@ -33,7 +34,8 @@ public enum Type TcpTls, Quic, WebSocket, - HttpSocket + HttpSocket, + InProcess } private int _counter; @@ -41,6 +43,7 @@ public enum Type private UnixDomainSocketEndPoint? _currentUdsPath; private int? _currentTcpPort; private int? _currentUdpPort; + private string? _currentInProcessEndpoint; private List Servers = new (); private List ServerFactories = new (); private List Clients = new(); @@ -111,6 +114,7 @@ public virtual void TearDown() CurrentUdsPath = null; _currentTcpPort = null; _currentUdpPort = null; + _currentInProcessEndpoint = null; _logger.LogEnabled = false; @@ -243,6 +247,12 @@ protected ServerConfig CreateServerConfigWithLog(Type type, INexusLogger? logger return new HttpSocketServerConfig() { Path = "/httpsocket-test", Logger = logger, }; } + if (type == Type.InProcess) + { + _currentInProcessEndpoint ??= $"inproc-{Guid.NewGuid():N}"; + return new InProcessServerConfig { Endpoint = _currentInProcessEndpoint, Logger = logger }; + } + throw new InvalidOperationException(); } @@ -331,14 +341,20 @@ protected ClientConfig CreateClientConfigWithLog(Type type, INexusLogger? logger _currentTcpPort ??= FreeTcpPort(); if(logger != null) logger.Behaviors |= NexusLogBehaviors.LogTransportData; - + return new HttpSocketClientConfig() { - Url = new Uri($"http://127.0.0.1:{_currentTcpPort}/httpsocket-test"), + Url = new Uri($"http://127.0.0.1:{_currentTcpPort}/httpsocket-test"), Logger = logger, }; } + if (type == Type.InProcess) + { + _currentInProcessEndpoint ??= $"inproc-{Guid.NewGuid():N}"; + return new InProcessClientConfig { Endpoint = _currentInProcessEndpoint, Logger = logger }; + } + throw new InvalidOperationException(); } diff --git a/src/NexNet.IntegrationTests/Collections/NexusCollectionAckTests.cs b/src/NexNet.IntegrationTests/Collections/NexusCollectionAckTests.cs index 9aba5f26..b5de683f 100644 --- a/src/NexNet.IntegrationTests/Collections/NexusCollectionAckTests.cs +++ b/src/NexNet.IntegrationTests/Collections/NexusCollectionAckTests.cs @@ -9,6 +9,7 @@ internal class NexusCollectionAckTests : NexusCollectionBaseTests { [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task UpdateAndWaitAsync_CompletesOnAcknowledgment(Type type) { var (server, client, _) = await ConnectServerAndClient(type); @@ -25,6 +26,7 @@ public async Task UpdateAndWaitAsync_CompletesOnAcknowledgment(Type type) [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task UpdateAndWaitAsync_TimesOutOnNoAcknowledgment(Type type) { var (server, client, _) = await ConnectServerAndClient(type); @@ -48,6 +50,7 @@ public async Task UpdateAndWaitAsync_TimesOutOnNoAcknowledgment(Type type) [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task MultipleOperations_ReceiveCorrectAcknowledgments(Type type) { var (server, client, _) = await ConnectServerAndClient(type); @@ -68,6 +71,7 @@ public async Task MultipleOperations_ReceiveCorrectAcknowledgments(Type type) [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task DisconnectDuringOperation_CompletesWithFalse(Type type) { var (server, client, _) = await ConnectServerAndClient(type); diff --git a/src/NexNet.IntegrationTests/Collections/NexusCollectionChannelTests.cs b/src/NexNet.IntegrationTests/Collections/NexusCollectionChannelTests.cs index aace301c..c9ff2250 100644 --- a/src/NexNet.IntegrationTests/Collections/NexusCollectionChannelTests.cs +++ b/src/NexNet.IntegrationTests/Collections/NexusCollectionChannelTests.cs @@ -9,6 +9,7 @@ internal class NexusCollectionChannelTests : NexusCollectionBaseTests { [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task ServerProcessChannel_HandlesCapacityLimit(Type type) { var (server, client, _) = await ConnectServerAndClient(type); @@ -35,6 +36,7 @@ public async Task ServerProcessChannel_HandlesCapacityLimit(Type type) [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task ClientMessageChannel_HandlesSaturation(Type type) { var (server, client, _) = await ConnectServerAndClient(type); @@ -62,6 +64,7 @@ public async Task ClientMessageChannel_HandlesSaturation(Type type) [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task ConcurrentOperations_DoNotBlockChannel(Type type) { var (server, client, _) = await ConnectServerAndClient(type); diff --git a/src/NexNet.IntegrationTests/Collections/NexusCollectionEventTests.cs b/src/NexNet.IntegrationTests/Collections/NexusCollectionEventTests.cs index 87f30852..7df35b70 100644 --- a/src/NexNet.IntegrationTests/Collections/NexusCollectionEventTests.cs +++ b/src/NexNet.IntegrationTests/Collections/NexusCollectionEventTests.cs @@ -7,6 +7,7 @@ internal class NexusCollectionEventTests : NexusCollectionBaseTests { [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task EventRacing_DoesNotCauseTimeout(Type type) { var (server, client, _) = await ConnectServerAndClient(type); @@ -31,6 +32,7 @@ public async Task EventRacing_DoesNotCauseTimeout(Type type) [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task MultipleEventSubscribers_HandleConcurrency(Type type) { var (server, client, _) = await ConnectServerAndClient(type); @@ -65,6 +67,7 @@ public async Task MultipleEventSubscribers_HandleConcurrency(Type type) [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task EventException_DoesNotBreakSystem(Type type) { var (server, client, _) = await ConnectServerAndClient(type); @@ -89,6 +92,7 @@ public async Task EventException_DoesNotBreakSystem(Type type) [TestCase(Type.Tcp)] [TestCase(Type.Uds)] + [TestCase(Type.InProcess)] public async Task ServerToClientCollection_ServerCanModify(Type type) { var (server, client, _) = CreateServerClient( diff --git a/src/NexNet.IntegrationTests/Infrastructure/TestProgressReporter.cs b/src/NexNet.IntegrationTests/Infrastructure/TestProgressReporter.cs new file mode 100644 index 00000000..d866d340 --- /dev/null +++ b/src/NexNet.IntegrationTests/Infrastructure/TestProgressReporter.cs @@ -0,0 +1,87 @@ +using System; +using System.Threading; +using NUnit.Framework; +using NUnit.Framework.Interfaces; + +// Apply at assembly scope so the action wraps every test in the assembly without each fixture +// having to opt in. +[assembly: NexNet.IntegrationTests.Infrastructure.TestProgressReporter] + +namespace NexNet.IntegrationTests.Infrastructure; + +/// +/// Assembly-level that emits one progress line per N tests OR every +/// T seconds — whichever fires first — so CI logs show "test session still alive" feedback for +/// long-running suites (the integration suite has ~2800 cases and a flat `--logger minimal` +/// run otherwise prints nothing for several minutes). +/// +/// +/// Output lines look like: +/// +/// [progress] NexNet.IntegrationTests: 100 ran (100 passed, 0 failed), 12.4s elapsed +/// +/// The line is written to : the dotnet test console logger +/// suppresses TestContext.Progress output at verbosity=minimal, but stderr +/// passes through untouched. Tests are free to use TestContext.Out / Error themselves; this +/// reporter doesn't interfere with that. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +internal sealed class TestProgressReporterAttribute : Attribute, ITestAction +{ + // Emit every N tests OR every T seconds, whichever comes first. + private const int EveryNTests = 100; + private static readonly TimeSpan EveryInterval = TimeSpan.FromSeconds(15); + + private static int _ran; + private static int _failed; + private static long _startTicks; + private static long _lastEmitTicks; + private static readonly object _gate = new(); + + public ActionTargets Targets => ActionTargets.Test; + + public void BeforeTest(ITest test) + { + // Capture session-start the first time any test is about to run, and seed the last- + // emit watermark with it so the time-cadence check doesn't fire spuriously on the + // very first test (where lastEmit=0 makes any elapsed look huge). + if (_startTicks == 0) + { + var now = Environment.TickCount64; + if (Interlocked.CompareExchange(ref _startTicks, now, 0) == 0) + Volatile.Write(ref _lastEmitTicks, now); + } + } + + public void AfterTest(ITest test) + { + var ran = Interlocked.Increment(ref _ran); + var outcome = TestContext.CurrentContext.Result.Outcome.Status; + if (outcome == TestStatus.Failed) + Interlocked.Increment(ref _failed); + + var nowTicks = Environment.TickCount64; + var elapsedSinceLastEmit = nowTicks - Volatile.Read(ref _lastEmitTicks); + var hitTestCadence = (ran % EveryNTests) == 0; + var hitTimeCadence = elapsedSinceLastEmit >= (long)EveryInterval.TotalMilliseconds; + + if (!hitTestCadence && !hitTimeCadence) + return; + + // Single emitter to keep output lines coherent. + lock (_gate) + { + if (Volatile.Read(ref _lastEmitTicks) > nowTicks - 100) + return; // another thread just emitted + + var elapsed = TimeSpan.FromMilliseconds(nowTicks - Volatile.Read(ref _startTicks)); + var assemblyName = typeof(TestProgressReporterAttribute).Assembly.GetName().Name; + var failed = Volatile.Read(ref _failed); + var passed = ran - failed; + // Stderr survives `console;verbosity=minimal`; TestContext.Progress does not. + Console.Error.WriteLine( + $"[progress] {assemblyName}: {ran} ran ({passed} passed, {failed} failed), {elapsed.TotalSeconds:F1}s elapsed"); + Volatile.Write(ref _lastEmitTicks, nowTicks); + } + } +} diff --git a/src/NexNet.IntegrationTests/InvocationInterceptorTests.cs b/src/NexNet.IntegrationTests/InvocationInterceptorTests.cs new file mode 100644 index 00000000..d1e2cbe8 --- /dev/null +++ b/src/NexNet.IntegrationTests/InvocationInterceptorTests.cs @@ -0,0 +1,135 @@ +using System.Collections.Concurrent; +using NexNet.Internals; +using NexNet.IntegrationTests.TestInterfaces; +using NexNet.Messages; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.IntegrationTests; + +internal class InvocationInterceptorTests : BaseTests +{ + [TestCase(Type.Tcp)] + [TestCase(Type.InProcess)] + public async Task NoInterceptor_InvocationsRunDirectly(Type type) + { + // Sanity check: when no interceptor is installed, server invocations dispatch normally. + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (server, client, _) = CreateServerClient( + CreateServerConfig(type), + CreateClientConfig(type)); + + server.OnNexusCreated = nexus => nexus.ServerVoidWithParamEvent = (_, p) => + { + if (p == 999) tcs.SetResult(); + }; + + await server.StartAsync().Timeout(1); + await client.ConnectAsync().Timeout(1); + + client.Proxy.ServerVoidWithParam(999); + + await tcs.Task.Timeout(1); + } + + [TestCase(Type.Tcp)] + [TestCase(Type.InProcess)] + public async Task Interceptor_WrapsEveryInvocation(Type type) + { + var interceptor = new CountingInterceptor(); + var serverConfig = CreateServerConfig(type); + serverConfig.InvocationInterceptor = interceptor; + + var invocationRan = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (server, client, _) = CreateServerClient( + serverConfig, + CreateClientConfig(type)); + + server.OnNexusCreated = nexus => nexus.ServerVoidWithParamEvent = (_, p) => + { + if (p == 42) invocationRan.SetResult(); + }; + + await server.StartAsync().Timeout(1); + await client.ConnectAsync().Timeout(1); + + client.Proxy.ServerVoidWithParam(42); + + await invocationRan.Task.Timeout(1); + // Give the interceptor's finally block a moment to record completion. + await interceptor.WaitForCompletionAsync().Timeout(1); + + Assert.That(interceptor.WrapStarted, Is.GreaterThanOrEqualTo(1)); + Assert.That(interceptor.WrapCompleted, Is.EqualTo(interceptor.WrapStarted)); + Assert.That(interceptor.ObservedMessages, Has.Some.Matches(m => m != null)); + } + + [TestCase(Type.Tcp)] + [TestCase(Type.InProcess)] + public async Task Interceptor_CanShortCircuitDispatch(Type type) + { + // If WrapAsync chooses not to invoke the inner delegate, the nexus method must not run. + // Demonstrates that the hook gives full control over dispatch. + var interceptor = new SkippingInterceptor(); + var serverConfig = CreateServerConfig(type); + serverConfig.InvocationInterceptor = interceptor; + + var serverInvoked = false; + var (server, client, _) = CreateServerClient( + serverConfig, + CreateClientConfig(type)); + + server.OnNexusCreated = nexus => nexus.ServerVoidWithParamEvent = (_, _) => + { + serverInvoked = true; + }; + + await server.StartAsync().Timeout(1); + await client.ConnectAsync().Timeout(1); + + client.Proxy.ServerVoidWithParam(1); + + await interceptor.SeenInvocation.Task.Timeout(1); + // The server method must NOT have been called. + Assert.That(serverInvoked, Is.False); + } + + private sealed class CountingInterceptor : IInvocationInterceptor + { + public int WrapStarted; + public int WrapCompleted; + public ConcurrentBag ObservedMessages { get; } = new(); + private TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async ValueTask WrapAsync(InvocationMessage message, Func invoke) + { + Interlocked.Increment(ref WrapStarted); + ObservedMessages.Add(message); + try + { + await invoke().ConfigureAwait(false); + } + finally + { + Interlocked.Increment(ref WrapCompleted); + _completion.TrySetResult(); + } + } + + public Task WaitForCompletionAsync() => _completion.Task; + } + + private sealed class SkippingInterceptor : IInvocationInterceptor + { + public TaskCompletionSource SeenInvocation { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ValueTask WrapAsync(InvocationMessage message, Func invoke) + { + SeenInvocation.TrySetResult(); + // Intentionally do NOT call invoke(). + return ValueTask.CompletedTask; + } + } +} diff --git a/src/NexNet.IntegrationTests/NexNet.IntegrationTests.csproj b/src/NexNet.IntegrationTests/NexNet.IntegrationTests.csproj index e65eff67..7e8243d4 100644 --- a/src/NexNet.IntegrationTests/NexNet.IntegrationTests.csproj +++ b/src/NexNet.IntegrationTests/NexNet.IntegrationTests.csproj @@ -36,6 +36,7 @@ + diff --git a/src/NexNet.IntegrationTests/NexusClientTests.cs b/src/NexNet.IntegrationTests/NexusClientTests.cs index ad2c385f..29730e62 100644 --- a/src/NexNet.IntegrationTests/NexusClientTests.cs +++ b/src/NexNet.IntegrationTests/NexusClientTests.cs @@ -16,6 +16,7 @@ internal partial class NexusClientTests : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusFiresOnConnected(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -41,6 +42,7 @@ public async Task NexusFiresOnConnected(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ConnectsToServer(Type type) { var clientConfig = CreateClientConfig(type); @@ -64,6 +66,7 @@ public async Task ConnectsToServer(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientFailsGracefullyWithNoServer(Type type) { var clientConfig = CreateClientConfig(type); @@ -82,6 +85,7 @@ public async Task ClientFailsGracefullyWithNoServer(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientTimesOutWithNoServer(Type type) { var clientConfig = CreateClientConfig(type); @@ -100,6 +104,7 @@ public async Task ClientTimesOutWithNoServer(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ConnectsAndDisconnectsMultipleTimesFromServer(Type type) { var (server, client, _) = CreateServerClient( @@ -123,6 +128,7 @@ public async Task ConnectsAndDisconnectsMultipleTimesFromServer(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ConnectTimesOutWithNoServer(Type type) { var (_, client, _, _, _) = CreateServerClientWithStoppedServer( @@ -138,6 +144,7 @@ public async Task ConnectTimesOutWithNoServer(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientProvidesAuthenticationToken(Type type) { var clientConfig = CreateClientConfig(type); @@ -172,6 +179,7 @@ public async Task ClientProvidesAuthenticationToken(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientSendsPing(Type type) { var clientConfig = CreateClientConfig(type); @@ -275,6 +283,7 @@ public async Task ReconnectsOnDisconnect(Type type) [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ReconnectsOnDisconnectAsp(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -317,6 +326,7 @@ public async Task ReconnectsOnDisconnectAsp(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ReconnectsOnTimeout(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -392,6 +402,7 @@ public async Task Client_ReconnectDelay_DrivenByFakeTime(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ReconnectsNotifiesReconnecting(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -445,6 +456,7 @@ public async Task ReconnectsNotifiesReconnecting_Hosted(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ReconnectsStopsAfterSpecifiedTimes(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -484,6 +496,7 @@ public async Task ReconnectsStopsAfterSpecifiedTimes(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientProxyInvocationCancelsOnDisconnect(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -528,6 +541,7 @@ public async Task ClientProxyInvocationCancelsOnDisconnect(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ReadyTaskCompletesUponConnection(Type type) { var (server, client, _) = CreateServerClient( @@ -545,6 +559,7 @@ public async Task ReadyTaskCompletesUponConnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ReadyTaskCompletesUponAuthentication(Type type) { var serverConfig = CreateServerConfig(type); @@ -573,6 +588,7 @@ public async Task ReadyTaskCompletesUponAuthentication(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ReadyTaskCompletesUponAuthFailure(Type type) { var serverConfig = CreateServerConfig(type); @@ -596,6 +612,7 @@ public async Task ReadyTaskCompletesUponAuthFailure(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task DisconnectTaskCompletesUponDisconnection(Type type) { var (server, client, _) = CreateServerClient( @@ -621,6 +638,7 @@ public async Task DisconnectTaskCompletesUponDisconnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task DisconnectTaskCompletesUponAuthFailure(Type type) { var serverConfig = CreateServerConfig(type); @@ -645,6 +663,7 @@ public async Task DisconnectTaskCompletesUponAuthFailure(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task DisconnectTaskCompletesAfterServerStops(Type type) { // Arrange @@ -669,6 +688,7 @@ public async Task DisconnectTaskCompletesAfterServerStops(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientSendsDisconnectSignal(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -697,6 +717,7 @@ public async Task ClientSendsDisconnectSignal(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task FiresOnDisconnectedEvent(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -723,6 +744,7 @@ public async Task FiresOnDisconnectedEvent(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ProxyInvocationPropagatesServerException(Type type) { var (server, client, _) = CreateServerClient( @@ -745,6 +767,7 @@ public async Task ProxyInvocationPropagatesServerException(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public void DisconnectWithoutConnectDoesNotThrow(Type type) { var (_, client, _) = CreateServerClient( @@ -762,6 +785,7 @@ public void DisconnectWithoutConnectDoesNotThrow(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task DoubleConnectDoesNotThrow(Type type) { var (server, client, _) = CreateServerClient( @@ -782,6 +806,7 @@ public async Task DoubleConnectDoesNotThrow(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task DoubleDisconnectDoesNotThrow(Type type) { var (server, client, _) = CreateServerClient( @@ -806,6 +831,7 @@ public async Task DoubleDisconnectDoesNotThrow(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ConcurrentProxyInvocations(Type type) { var (server, client, _) = CreateServerClient( @@ -865,6 +891,7 @@ public async Task ServerRejectsAuth_SendsDisconnect(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ProxyInvocationAfterDisconnectThrows(Type type) { var (server, client, _) = CreateServerClient( @@ -891,6 +918,7 @@ public async Task ProxyInvocationAfterDisconnectThrows(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task OnReconnectingEventNotFiredWithNoRetries(Type type) { var clientConfig = CreateClientConfig(type); @@ -929,6 +957,7 @@ public async Task OnReconnectingEventNotFiredWithNoRetries(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task StartAsyncTwiceThrows(Type type) { var server = CreateServer(CreateServerConfig(type), /*listenerFactory*/ null); diff --git a/src/NexNet.IntegrationTests/NexusClientTests_Cancellation.cs b/src/NexNet.IntegrationTests/NexusClientTests_Cancellation.cs index 7be1d547..702d95a5 100644 --- a/src/NexNet.IntegrationTests/NexusClientTests_Cancellation.cs +++ b/src/NexNet.IntegrationTests/NexusClientTests_Cancellation.cs @@ -16,6 +16,7 @@ internal partial class NexusClientTests_Cancellation : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientSendsCancellationTokenOnClientSideTimeout_ServerTaskValueWithParam(Type type) { var tcs = await ClientSendsMessage( @@ -48,6 +49,7 @@ public async Task ClientSendsCancellationTokenOnClientSideTimeout_ServerTaskValu [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientSendsCancellationTokenOnClientSideTimeout_ServerTaskWithValueAndCancellation(Type type) { var tcs = await ClientSendsMessage( @@ -81,6 +83,7 @@ public async Task ClientSendsCancellationTokenOnClientSideTimeout_ServerTaskWith [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientSendsCancellationTokenOnClientSideTimeout_ServerTaskValueWithCancellation(Type type) { var tcs = await ClientSendsMessage( @@ -110,6 +113,7 @@ public async Task ClientSendsCancellationTokenOnClientSideTimeout_ServerTaskValu [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientSendsCancellationTokenOnClientSideTimeout_ServerTaskValueWithValueAndCancellation(Type type) { var tcs = await ClientSendsMessage( @@ -138,6 +142,7 @@ public async Task ClientSendsCancellationTokenOnClientSideTimeout_ServerTaskValu [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientDoesNotSendCancellationAfterCompletion(Type type) { var tcs = await ClientSendsMessage( diff --git a/src/NexNet.IntegrationTests/NexusClientTests_InvalidInvocations.cs b/src/NexNet.IntegrationTests/NexusClientTests_InvalidInvocations.cs index de3c7784..0953f8f4 100644 --- a/src/NexNet.IntegrationTests/NexusClientTests_InvalidInvocations.cs +++ b/src/NexNet.IntegrationTests/NexusClientTests_InvalidInvocations.cs @@ -11,6 +11,7 @@ internal partial class NexusClientTests_InvalidInvocations : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientThrowsWhenArgumentTooLarge(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/NexNet.IntegrationTests/NexusClientTests_ReceiveInvocation.cs b/src/NexNet.IntegrationTests/NexusClientTests_ReceiveInvocation.cs index 2837fe69..153ae8da 100644 --- a/src/NexNet.IntegrationTests/NexusClientTests_ReceiveInvocation.cs +++ b/src/NexNet.IntegrationTests/NexusClientTests_ReceiveInvocation.cs @@ -13,6 +13,7 @@ internal partial class NexusClientTests_ReceiveInvocation : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientVoid(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -32,6 +33,7 @@ public Task ClientReceivesInvocation_ClientVoid(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientVoidWithParam(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -55,6 +57,7 @@ public Task ClientReceivesInvocation_ClientVoidWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTask(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -74,6 +77,7 @@ public Task ClientReceivesInvocation_ClientTask(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskWithParam(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -95,6 +99,7 @@ public Task ClientReceivesInvocation_ClientTaskWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskValue(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -114,6 +119,7 @@ public Task ClientReceivesInvocation_ClientTaskValue(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskValue_ReturnedValue(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -135,6 +141,7 @@ public Task ClientReceivesInvocation_ClientTaskValue_ReturnedValue(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskValueWithParam(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -156,6 +163,7 @@ public Task ClientReceivesInvocation_ClientTaskValueWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskValueWithParam_ReturnedValue(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -178,6 +186,7 @@ public Task ClientReceivesInvocation_ClientTaskValueWithParam_ReturnedValue(Type [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskWithCancellation(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -198,6 +207,7 @@ public Task ClientReceivesInvocation_ClientTaskWithCancellation(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskWithValueAndCancellation(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -218,6 +228,7 @@ public Task ClientReceivesInvocation_ClientTaskWithValueAndCancellation(Type typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskValueWithCancellation(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -237,6 +248,7 @@ public Task ClientReceivesInvocation_ClientTaskValueWithCancellation(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskValueWithCancellation_ReturnedValue(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -257,6 +269,7 @@ public Task ClientReceivesInvocation_ClientTaskValueWithCancellation_ReturnedVal [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskValueWithValueAndCancellation(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -278,6 +291,7 @@ public Task ClientReceivesInvocation_ClientTaskValueWithValueAndCancellation(Typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientReceivesInvocation_ClientTaskValueWithValueAndCancellation_ReturnedValue(Type type) { return ClientReceivesInvocation(type, (sNexus, cNexus, tcs) => diff --git a/src/NexNet.IntegrationTests/NexusClientTests_SendInvocation.cs b/src/NexNet.IntegrationTests/NexusClientTests_SendInvocation.cs index 6b4e84f8..843b076e 100644 --- a/src/NexNet.IntegrationTests/NexusClientTests_SendInvocation.cs +++ b/src/NexNet.IntegrationTests/NexusClientTests_SendInvocation.cs @@ -14,6 +14,7 @@ internal partial class NexusClientTests_SendInvocation : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerVoid(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() @@ -32,6 +33,7 @@ public Task ClientSendsInvocationFor_ServerVoid(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerVoidWithParam(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() @@ -50,6 +52,7 @@ public Task ClientSendsInvocationFor_ServerVoidWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerTask(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() @@ -68,6 +71,7 @@ public Task ClientSendsInvocationFor_ServerTask(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerTaskWithParam(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() @@ -86,6 +90,7 @@ public Task ClientSendsInvocationFor_ServerTaskWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerTaskValue(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() @@ -104,6 +109,7 @@ public Task ClientSendsInvocationFor_ServerTaskValue(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerTaskValueWithParam(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() @@ -123,6 +129,7 @@ public Task ClientSendsInvocationFor_ServerTaskValueWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerTaskWithCancellation(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() @@ -141,6 +148,7 @@ public Task ClientSendsInvocationFor_ServerTaskWithCancellation(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerTaskWithValueAndCancellation(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() @@ -159,6 +167,7 @@ public Task ClientSendsInvocationFor_ServerTaskWithValueAndCancellation(Type typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerTaskValueWithCancellation(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() @@ -177,6 +186,7 @@ public Task ClientSendsInvocationFor_ServerTaskValueWithCancellation(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ClientSendsInvocationFor_ServerTaskValueWithValueAndCancellation(Type type) { return InvokeFromClientAndVerifySent(type, new InvocationMessage() diff --git a/src/NexNet.IntegrationTests/NexusServerTests.cs b/src/NexNet.IntegrationTests/NexusServerTests.cs index 7155cf06..78dc0bb1 100644 --- a/src/NexNet.IntegrationTests/NexusServerTests.cs +++ b/src/NexNet.IntegrationTests/NexusServerTests.cs @@ -18,6 +18,7 @@ internal partial class NexusServerTests : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task AcceptsClientConnection(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -41,6 +42,7 @@ public async Task AcceptsClientConnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusFiresOnConnected(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -176,6 +178,7 @@ public void ServerThrowsWhenStartingTwiceWhileAlreadyRunning() [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ServerFiresOnDisconnectedEvent(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -236,6 +239,7 @@ public async Task ServerRejectsAuthAndSendsDisconnect(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public void StopWithoutStartThrows(Type type) { var server = CreateServer(CreateServerConfig(type), /*listenerFactory*/ null); @@ -252,6 +256,7 @@ public void StopWithoutStartThrows(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task OnConnectedEventFiresAfterAuthentication(Type type) { var serverConfig = CreateServerConfig(type); @@ -287,12 +292,14 @@ public async Task OnConnectedEventFiresAfterAuthentication(Type type) [TestCase(Type.TcpTls, true)] [TestCase(Type.WebSocket, true)] [TestCase(Type.HttpSocket, true)] + [TestCase(Type.InProcess, true)] [TestCase(Type.Quic, false)] [TestCase(Type.Uds, false)] [TestCase(Type.Tcp, false)] [TestCase(Type.TcpTls, false)] [TestCase(Type.WebSocket, false)] [TestCase(Type.HttpSocket, false)] + [TestCase(Type.InProcess, false)] public async Task ConnectAsyncThrowsAndOnConnectedNeverFiresWhenAuthHandlerThrows(Type type, bool authenticateClient) { // Arrange: force server to require auth, and make its auth handler throw @@ -335,6 +342,7 @@ await AssertThrows(async () => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task OnDisconnectedEventExceptionDoesNotBreakServer(Type type) { var serverConfig = CreateServerConfig(type); diff --git a/src/NexNet.IntegrationTests/NexusServerTests_Authorization.cs b/src/NexNet.IntegrationTests/NexusServerTests_Authorization.cs index 78ab8d98..04b3144b 100644 --- a/src/NexNet.IntegrationTests/NexusServerTests_Authorization.cs +++ b/src/NexNet.IntegrationTests/NexusServerTests_Authorization.cs @@ -95,6 +95,7 @@ public override void TearDown() [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task AuthorizedMethod_Allowed_InvokesMethod(Type type) { var methodInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -123,6 +124,7 @@ public async Task AuthorizedMethod_Allowed_InvokesMethod(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task AuthorizedMethod_Unauthorized_ThrowsOnClient(Type type) { var (server, client, _) = CreateAuthServerClient(type); @@ -145,6 +147,7 @@ public async Task AuthorizedMethod_Unauthorized_ThrowsOnClient(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task AuthorizedMethod_Disconnect_DisconnectsSession(Type type) { var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -179,6 +182,7 @@ public async Task AuthorizedMethod_Disconnect_DisconnectsSession(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task UnprotectedMethod_NoAuthCheck(Type type) { var authCalled = false; @@ -213,6 +217,7 @@ public async Task UnprotectedMethod_NoAuthCheck(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task MarkerOnly_CallsOnAuthorizeWithEmptyPermissions(Type type) { ReadOnlyMemory capturedPermissions = default; @@ -244,6 +249,7 @@ public async Task MarkerOnly_CallsOnAuthorizeWithEmptyPermissions(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task OnAuthorize_ReceivesCorrectMethodName(Type type) { string? capturedName = null; @@ -275,6 +281,7 @@ public async Task OnAuthorize_ReceivesCorrectMethodName(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task OnAuthorize_ReceivesCorrectPermissions(Type type) { ReadOnlyMemory capturedPermissions = default; @@ -307,6 +314,7 @@ public async Task OnAuthorize_ReceivesCorrectPermissions(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task OnAuthorize_ReceivesMultiplePermissions(Type type) { ReadOnlyMemory capturedPermissions = default; @@ -340,6 +348,7 @@ public async Task OnAuthorize_ReceivesMultiplePermissions(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Authorized_WithReturnValue_ReturnsResult(Type type) { var (server, client, _) = CreateAuthServerClient(type); @@ -363,6 +372,7 @@ public async Task Authorized_WithReturnValue_ReturnsResult(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Unauthorized_WithReturnValue_ThrowsNotReturns(Type type) { var (server, client, _) = CreateAuthServerClient(type); @@ -385,6 +395,7 @@ public async Task Unauthorized_WithReturnValue_ThrowsNotReturns(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Unauthorized_MethodBodyNeverExecutes(Type type) { var bodyExecuted = false; @@ -416,6 +427,7 @@ public async Task Unauthorized_MethodBodyNeverExecutes(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task AuthorizedCollection_Allowed_ClientReceivesData(Type type) { var (server, client, _) = CreateAuthServerClient(type); @@ -447,6 +459,7 @@ public async Task AuthorizedCollection_Allowed_ClientReceivesData(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task AuthorizedCollection_Unauthorized_ClientDisconnected(Type type) { var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -482,6 +495,7 @@ public async Task AuthorizedCollection_Unauthorized_ClientDisconnected(Type type [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task UnprotectedCollection_NoAuthCheck(Type type) { var authCalled = false; @@ -510,6 +524,7 @@ public async Task UnprotectedCollection_NoAuthCheck(Type type) [TestCase(Type.Quic)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task OnAuthorize_ThrowsException_TreatedAsDisconnect(Type type) { var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/NexNet.IntegrationTests/NexusServerTests_Cancellation.cs b/src/NexNet.IntegrationTests/NexusServerTests_Cancellation.cs index 114b00fc..04157670 100644 --- a/src/NexNet.IntegrationTests/NexusServerTests_Cancellation.cs +++ b/src/NexNet.IntegrationTests/NexusServerTests_Cancellation.cs @@ -16,6 +16,7 @@ internal partial class NexusServerTests_Cancellation : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task SendsCancellationTokenOnTimeout_ClientTaskValueWithParam(Type type) { var tcs = await ServerSendsMessage( @@ -48,6 +49,7 @@ public async Task SendsCancellationTokenOnTimeout_ClientTaskValueWithParam(Type [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task SendsCancellationTokenOnTimeout_ServerTaskWithValueAndCancellation(Type type) { var tcs = await ServerSendsMessage( @@ -81,6 +83,7 @@ public async Task SendsCancellationTokenOnTimeout_ServerTaskWithValueAndCancella [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task SendsCancellationTokenOnTimeout_ServerTaskValueWithCancellation(Type type) { var tcs = await ServerSendsMessage( @@ -110,6 +113,7 @@ public async Task SendsCancellationTokenOnTimeout_ServerTaskValueWithCancellatio [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task SendsCancellationTokenOnTimeout_ServerTaskValueWithValueAndCancellation(Type type) { var tcs = await ServerSendsMessage( @@ -138,6 +142,7 @@ public async Task SendsCancellationTokenOnTimeout_ServerTaskValueWithValueAndCan [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task ClientDoesNotSendCancellationAfterCompletion(Type type) { var tcs = await ServerSendsMessage( diff --git a/src/NexNet.IntegrationTests/NexusServerTests_NexusGroupInvocations.cs b/src/NexNet.IntegrationTests/NexusServerTests_NexusGroupInvocations.cs index bc0f100f..94730125 100644 --- a/src/NexNet.IntegrationTests/NexusServerTests_NexusGroupInvocations.cs +++ b/src/NexNet.IntegrationTests/NexusServerTests_NexusGroupInvocations.cs @@ -14,6 +14,7 @@ internal class NexusServerTests_NexusGroupInvocations : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusInvokesOnGroup(Type type) { await RunGroupTest(type, ["group"], 3, 3, nexus => @@ -27,6 +28,7 @@ await RunGroupTest(type, ["group"], 3, 3, nexus => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusInvokesOnGroupExceptCaller(Type type) { await RunGroupTest(type, ["group"], 3, 2, nexus => @@ -40,6 +42,7 @@ await RunGroupTest(type, ["group"], 3, 2, nexus => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusInvokesOnGroups(Type type) { await RunGroupTest(type, ["group1", "group2"], 3, 6, nexus => @@ -53,6 +56,7 @@ await RunGroupTest(type, ["group1", "group2"], 3, 6, nexus => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusInvokesOnGroupsExceptCaller(Type type) { await RunGroupTest(type, ["group1", "group2"], 3, 4, nexus => @@ -66,6 +70,7 @@ await RunGroupTest(type, ["group1", "group2"], 3, 4, nexus => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task DirectNexusProxyInvokesOnGroupIncludingCurrent(Type type) { await RunServerContextGroupTest(type, ["group"], 3, 3, proxy => @@ -78,6 +83,7 @@ await RunServerContextGroupTest(type, ["group"], 3, 3, proxy => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task DirectNexusProxyInvokesOnGroupsIncludingCurrent(Type type) { await RunServerContextGroupTest(type, ["group1","group2"], 3, 6, proxy => @@ -90,6 +96,7 @@ await RunServerContextGroupTest(type, ["group1","group2"], 3, 6, proxy => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task DirectNexusProxyInvokesOnGroupIgnoresExcludingCurrent(Type type) { await RunServerContextGroupTest(type, ["group"], 3, 3, proxy => @@ -102,6 +109,7 @@ await RunServerContextGroupTest(type, ["group"], 3, 3, proxy => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task DirectNexusProxyInvokesOnGroupsIgnoresIncludingCurrent(Type type) { await RunServerContextGroupTest(type, ["group1","group2"], 3, 6, proxy => diff --git a/src/NexNet.IntegrationTests/NexusServerTests_NexusInvocations.cs b/src/NexNet.IntegrationTests/NexusServerTests_NexusInvocations.cs index 2eeb8f09..2ef01dbd 100644 --- a/src/NexNet.IntegrationTests/NexusServerTests_NexusInvocations.cs +++ b/src/NexNet.IntegrationTests/NexusServerTests_NexusInvocations.cs @@ -12,6 +12,7 @@ internal class NexusServerTests_NexusInvocations : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task InvokesViaNexusContext(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -36,6 +37,7 @@ public async Task InvokesViaNexusContext(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task InvokesViaNexusContextAndDoesNotBlock(Type type) { bool completed = false; @@ -71,6 +73,7 @@ public async Task InvokesViaNexusContextAndDoesNotBlock(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task InvokesViaNexusAndDoesNotBlock(Type type) { bool completed = false; @@ -117,6 +120,7 @@ public async Task InvokesViaNexusAndDoesNotBlock(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task InvokesViaNexusContextAndGetsReturnFromSingleClient(Type type) { var serverConfig = CreateServerConfig(type); @@ -160,6 +164,7 @@ public async Task InvokesViaNexusContextAndGetsReturnFromSingleClient(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusInvokesOnAll(Type type) { var tcs1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -199,6 +204,7 @@ public async Task NexusInvokesOnAll(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusInvokesOnOthers(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -241,6 +247,7 @@ public async Task NexusInvokesOnOthers(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusInvokesOnClient(Type type) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -283,6 +290,7 @@ public async Task NexusInvokesOnClient(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task NexusInvokesOnClients(Type type) { var tcs1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/NexNet.IntegrationTests/NexusServerTests_ReceiveInvocation.cs b/src/NexNet.IntegrationTests/NexusServerTests_ReceiveInvocation.cs index 972bfa39..8df6eb67 100644 --- a/src/NexNet.IntegrationTests/NexusServerTests_ReceiveInvocation.cs +++ b/src/NexNet.IntegrationTests/NexusServerTests_ReceiveInvocation.cs @@ -13,6 +13,7 @@ internal class NexusServerTests_ReceiveInvocation : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerVoid(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -32,6 +33,7 @@ public Task ServerReceivesInvocation_ServerVoid(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerVoidWithParam(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -55,6 +57,7 @@ public Task ServerReceivesInvocation_ServerVoidWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerTask(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -75,6 +78,7 @@ public Task ServerReceivesInvocation_ServerTask(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerTaskWithParam(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -96,6 +100,7 @@ public Task ServerReceivesInvocation_ServerTaskWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerTaskValue(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -115,6 +120,7 @@ public Task ServerReceivesInvocation_ServerTaskValue(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerTaskValue_ReturnedValue(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -136,6 +142,7 @@ public Task ServerReceivesInvocation_ServerTaskValue_ReturnedValue(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerTaskValueWithParam(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -157,6 +164,7 @@ public Task ServerReceivesInvocation_ServerTaskValueWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerTaskValueWithParam_ReturnedValue(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -179,6 +187,7 @@ public Task ServerReceivesInvocation_ServerTaskValueWithParam_ReturnedValue(Type [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerTaskWithCancellation(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -199,6 +208,7 @@ public Task ServerReceivesInvocation_ServerTaskWithCancellation(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ServerTaskWithValueAndCancellation(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -220,6 +230,7 @@ public Task ServerReceivesInvocation_ServerTaskWithValueAndCancellation(Type typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ClientTaskValueWithCancellation(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -239,6 +250,7 @@ public Task ServerReceivesInvocation_ClientTaskValueWithCancellation(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ClientTaskValueWithCancellation_ReturnedValue(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -259,6 +271,7 @@ public Task ServerReceivesInvocation_ClientTaskValueWithCancellation_ReturnedVal [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ClientTaskValueWithValueAndCancellation(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => @@ -280,6 +293,7 @@ public Task ServerReceivesInvocation_ClientTaskValueWithValueAndCancellation(Typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerReceivesInvocation_ClientTaskValueWithValueAndCancellation_ReturnedValue(Type type) { return ServerReceivesInvocation(type, (sNexus, cNexus, tcs) => diff --git a/src/NexNet.IntegrationTests/NexusServerTests_SendInvocation.cs b/src/NexNet.IntegrationTests/NexusServerTests_SendInvocation.cs index c38939ca..4f985162 100644 --- a/src/NexNet.IntegrationTests/NexusServerTests_SendInvocation.cs +++ b/src/NexNet.IntegrationTests/NexusServerTests_SendInvocation.cs @@ -14,6 +14,7 @@ internal partial class NexusServerTests_SendInvocation : BaseTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerSendsInvocationFor_ServerVoid(Type type) { return InvokeFromServerAndVerifySent(type, new InvocationMessage() @@ -32,6 +33,7 @@ public Task ServerSendsInvocationFor_ServerVoid(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerSendsInvocationFor_ServerVoidWithParam(Type type) { return InvokeFromServerAndVerifySent(type, new InvocationMessage() @@ -50,6 +52,7 @@ public Task ServerSendsInvocationFor_ServerVoidWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerSendsInvocationFor_ServerTask(Type type) { return InvokeFromServerAndVerifySent(type, new InvocationMessage() @@ -68,6 +71,7 @@ public Task ServerSendsInvocationFor_ServerTask(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerSendsInvocationFor_ServerTaskWithParam(Type type) { return InvokeFromServerAndVerifySent(type, new InvocationMessage() @@ -101,6 +105,7 @@ public Task ServerSendsInvocationFor_ServerTaskValue(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerSendsInvocationFor_ServerTaskValueWithParam(Type type) { return InvokeFromServerAndVerifySent(type, new InvocationMessage() @@ -120,6 +125,7 @@ public Task ServerSendsInvocationFor_ServerTaskValueWithParam(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerSendsInvocationFor_ServerTaskWithCancellation(Type type) { return InvokeFromServerAndVerifySent(type, new InvocationMessage() @@ -138,6 +144,7 @@ public Task ServerSendsInvocationFor_ServerTaskWithCancellation(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerSendsInvocationFor_ServerTaskWithValueAndCancellation(Type type) { return InvokeFromServerAndVerifySent(type, new InvocationMessage() @@ -156,6 +163,7 @@ public Task ServerSendsInvocationFor_ServerTaskWithValueAndCancellation(Type typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerSendsInvocationFor_ServerTaskValueWithCancellation(Type type) { return InvokeFromServerAndVerifySent(type, new InvocationMessage() @@ -174,6 +182,7 @@ public Task ServerSendsInvocationFor_ServerTaskValueWithCancellation(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public Task ServerSendsInvocationFor_ServerTaskValueWithValueAndCancellation(Type type) { return InvokeFromServerAndVerifySent(type, new InvocationMessage() diff --git a/src/NexNet.IntegrationTests/OnAuthenticateOverrideTests.cs b/src/NexNet.IntegrationTests/OnAuthenticateOverrideTests.cs new file mode 100644 index 00000000..5b25a711 --- /dev/null +++ b/src/NexNet.IntegrationTests/OnAuthenticateOverrideTests.cs @@ -0,0 +1,92 @@ +using NexNet.IntegrationTests.TestInterfaces; +using NexNet.Messages; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.IntegrationTests; + +internal class OnAuthenticateOverrideTests : BaseTests +{ + [TestCase(Type.Tcp)] + [TestCase(Type.InProcess)] + public async Task NoOverride_OnAuthenticateIsCalled(Type type) + { + // Baseline: when no override is installed, the nexus's OnAuthenticate is the auth source. + var serverConfig = CreateServerConfig(type); + serverConfig.Authenticate = true; + + var onAuthenticateCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (server, client, _) = CreateServerClient(serverConfig, CreateClientConfig(type)); + + server.OnNexusCreated = nexus => + { + nexus.OnAuthenticateEvent = _ => + { + onAuthenticateCalled.TrySetResult(); + return ValueTask.FromResult(new DefaultIdentity()); + }; + }; + + await server.StartAsync().Timeout(1); + await client.ConnectAsync().Timeout(1); + + await onAuthenticateCalled.Task.Timeout(1); + } + + [TestCase(Type.Tcp)] + [TestCase(Type.InProcess)] + public async Task Override_ConsultedInsteadOfOnAuthenticate(Type type) + { + var serverConfig = CreateServerConfig(type); + serverConfig.Authenticate = true; + + var overrideCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var onAuthenticateCalled = false; + + serverConfig.OnAuthenticateOverride = _ => + { + overrideCalled.TrySetResult(); + return ValueTask.FromResult(new DefaultIdentity()); + }; + + var (server, client, _) = CreateServerClient(serverConfig, CreateClientConfig(type)); + + server.OnNexusCreated = nexus => + { + nexus.OnAuthenticateEvent = _ => + { + onAuthenticateCalled = true; + return ValueTask.FromResult(new DefaultIdentity()); + }; + }; + + await server.StartAsync().Timeout(1); + await client.ConnectAsync().Timeout(1); + + await overrideCalled.Task.Timeout(1); + Assert.That(onAuthenticateCalled, Is.False, "OnAuthenticate must not be called when override is installed"); + } + + [TestCase(Type.Tcp)] + [TestCase(Type.InProcess)] + public async Task Override_NullIdentity_DisconnectsClient(Type type) + { + // The override returning null mirrors OnAuthenticate-returns-null behavior: server sends auth-disconnect. + var disconnectSent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var serverConfig = CreateServerConfig(type); + serverConfig.Authenticate = true; + serverConfig.OnAuthenticateOverride = _ => ValueTask.FromResult(null); + serverConfig.InternalOnSend = (_, bytes) => + { + if (bytes is [(byte)MessageType.DisconnectAuthentication]) + disconnectSent.TrySetResult(); + }; + + var (server, client, _) = CreateServerClient(serverConfig, CreateClientConfig(type)); + + await server.StartAsync().Timeout(1); + await client.TryConnectAsync().Timeout(1); + + await disconnectSent.Task.Timeout(1); + } +} diff --git a/src/NexNet.IntegrationTests/PipeFactoryHookTests.cs b/src/NexNet.IntegrationTests/PipeFactoryHookTests.cs new file mode 100644 index 00000000..597003af --- /dev/null +++ b/src/NexNet.IntegrationTests/PipeFactoryHookTests.cs @@ -0,0 +1,96 @@ +using NexNet.Internals; +using NexNet.IntegrationTests.TestInterfaces; +using NexNet.Pipes; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.IntegrationTests; + +internal class PipeFactoryHookTests : BaseTests +{ + [TestCase(Type.Tcp)] + [TestCase(Type.InProcess)] + public async Task NoFactory_PipeBehavesNormally(Type type) + { + // Sanity check: with no factory installed, pipe round-trip still works. + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (server, client, cNexus) = CreateServerClient( + CreateServerConfig(type), + CreateClientConfig(type)); + + cNexus.ClientTaskValueWithDuplexPipeEvent = async (_, pipe) => + { + var result = await pipe.Input.ReadAsync(); + if (!result.IsCompleted) + tcs.SetResult(); + }; + + await server.StartAsync().Timeout(1); + await client.ConnectAsync().Timeout(1); + + var sNexus = server.NexusCreatedQueue.First(); + await using var pipe = sNexus.Context.CreatePipe(); + await sNexus.Context.Clients.Caller.ClientTaskValueWithDuplexPipe(pipe).Timeout(1); + await pipe.ReadyTask.Timeout(1); + await pipe.Output.WriteAsync(new byte[] { 1, 2, 3 }).Timeout(1); + + await tcs.Task.Timeout(1); + } + + [TestCase(Type.Tcp)] + [TestCase(Type.InProcess)] + public async Task Factory_WrapsLocalAndRemotePipes(Type type) + { + var serverFactory = new CountingPipeFactory(); + var clientFactory = new CountingPipeFactory(); + + var serverConfig = CreateServerConfig(type); + var clientConfig = CreateClientConfig(type); + serverConfig.PipeFactory = serverFactory; + clientConfig.PipeFactory = clientFactory; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (server, client, cNexus) = CreateServerClient(serverConfig, clientConfig); + + cNexus.ClientTaskValueWithDuplexPipeEvent = async (_, pipe) => + { + var result = await pipe.Input.ReadAsync(); + if (!result.IsCompleted) + tcs.SetResult(); + }; + + await server.StartAsync().Timeout(1); + await client.ConnectAsync().Timeout(1); + + var sNexus = server.NexusCreatedQueue.First(); + await using var pipe = sNexus.Context.CreatePipe(); + await sNexus.Context.Clients.Caller.ClientTaskValueWithDuplexPipe(pipe).Timeout(1); + await pipe.ReadyTask.Timeout(1); + await pipe.Output.WriteAsync(new byte[] { 1, 2, 3 }).Timeout(1); + + await tcs.Task.Timeout(1); + + // The server side rented the pipe (WrapLocal); the client side registered the + // remote pipe in response (WrapRemote). + Assert.That(serverFactory.LocalWrapped, Is.GreaterThanOrEqualTo(1), "Server WrapLocal"); + Assert.That(clientFactory.RemoteWrapped, Is.GreaterThanOrEqualTo(1), "Client WrapRemote"); + } + + private sealed class CountingPipeFactory : IPipeFactory + { + public int LocalWrapped; + public int RemoteWrapped; + + public IRentedNexusDuplexPipe WrapLocal(IRentedNexusDuplexPipe inner) + { + Interlocked.Increment(ref LocalWrapped); + return inner; + } + + public INexusDuplexPipe WrapRemote(INexusDuplexPipe inner) + { + Interlocked.Increment(ref RemoteWrapped); + return inner; + } + } +} diff --git a/src/NexNet.IntegrationTests/Pipes/NexusClientTests_ChanneReaderIAsyncEnumerable.cs b/src/NexNet.IntegrationTests/Pipes/NexusClientTests_ChanneReaderIAsyncEnumerable.cs index 8a95aa82..b81c92ca 100644 --- a/src/NexNet.IntegrationTests/Pipes/NexusClientTests_ChanneReaderIAsyncEnumerable.cs +++ b/src/NexNet.IntegrationTests/Pipes/NexusClientTests_ChanneReaderIAsyncEnumerable.cs @@ -11,6 +11,7 @@ internal class NexusClientTests_ChanneReaderIAsyncEnumerable : BasePipeTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task IAsyncEnumerable(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -58,6 +59,7 @@ public async Task IAsyncEnumerable(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task StopsOnCompletion(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -89,6 +91,7 @@ public async Task StopsOnCompletion(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task CancelsRead(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -120,6 +123,7 @@ public async Task CancelsRead(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task CancelsAndResumesRead(Type type) { var (server, _, cNexus, _) = await Setup(type); diff --git a/src/NexNet.IntegrationTests/Pipes/NexusClientTests_NexusDuplexPipe.cs b/src/NexNet.IntegrationTests/Pipes/NexusClientTests_NexusDuplexPipe.cs index c02417a4..6c1d26e0 100644 --- a/src/NexNet.IntegrationTests/Pipes/NexusClientTests_NexusDuplexPipe.cs +++ b/src/NexNet.IntegrationTests/Pipes/NexusClientTests_NexusDuplexPipe.cs @@ -12,6 +12,7 @@ internal class NexusClientTests_NexusDuplexPipe : BasePipeTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeReaderReceivesDataMultipleTimes(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -51,6 +52,7 @@ public async Task Client_PipeReaderReceivesDataMultipleTimes(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeReaderReceivesDataMultipleTimesWithLargeData(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -87,6 +89,7 @@ public async Task Client_PipeReaderReceivesDataMultipleTimesWithLargeData(Type t [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeReaderReceivesData(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -114,6 +117,7 @@ public async Task Client_PipeReaderReceivesData(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeWriterSendsData(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -138,6 +142,7 @@ public async Task Client_PipeWriterSendsData(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeReaderCompletesUponPipeCompleteAsync(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -164,6 +169,7 @@ public async Task Client_PipeReaderCompletesUponPipeCompleteAsync(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeWriterCompletesUponCompleteAsync(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -200,6 +206,7 @@ public async Task Client_PipeWriterCompletesUponCompleteAsync(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeReaderCompletesUponDisconnection(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -228,6 +235,7 @@ public async Task Client_PipeReaderCompletesUponDisconnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeWriterCompletesUponDisconnection(Type type) { var tcsDisconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -260,6 +268,7 @@ public async Task Client_PipeWriterCompletesUponDisconnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeReaderCompletesUponWriterCompletion(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -289,6 +298,7 @@ public async Task Client_PipeReaderCompletesUponWriterCompletion(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeWriterCompletesUponWriterCompletion(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -323,6 +333,7 @@ public async Task Client_PipeWriterCompletesUponWriterCompletion(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeWriterRemainsOpenUponOtherWriterCompletion(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -353,6 +364,7 @@ public async Task Client_PipeWriterRemainsOpenUponOtherWriterCompletion(Type typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeReaderRemainsOpenUponOtherReaderCompletion(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -393,6 +405,7 @@ public async Task Client_PipeReaderRemainsOpenUponOtherReaderCompletion(Type typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeNotifiesWhenReady(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -417,6 +430,7 @@ public async Task Client_PipeNotifiesWhenReady(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeReadyCancelsOnDisconnection(Type type) { var (server, client, _, _) = await Setup(type); @@ -441,6 +455,7 @@ public async Task Client_PipeReadyCancelsOnDisconnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeCompleteCancelsOnDisconnection(Type type) { var (server, client, _, _) = await Setup(type); @@ -465,6 +480,7 @@ public async Task Client_PipeCompleteCancelsOnDisconnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_PipeNotifiesWhenComplete(Type type) { var (server, client, _, _) = await Setup(type); @@ -492,6 +508,7 @@ public async Task Client_PipeNotifiesWhenComplete(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_ThrowsWhenPassingPipeFromWrongNexus(Type type) { var (server, client, _, _) = await Setup(type); @@ -512,6 +529,7 @@ await AssertThrows(() => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Client_ThrowsWhenPassingUsedPipe(Type type) { var (server, client, _, _) = await Setup(type); diff --git a/src/NexNet.IntegrationTests/Pipes/NexusServerTests_ChanneReaderIAsyncEnumerable.cs b/src/NexNet.IntegrationTests/Pipes/NexusServerTests_ChanneReaderIAsyncEnumerable.cs index a92a5b91..bba7b648 100644 --- a/src/NexNet.IntegrationTests/Pipes/NexusServerTests_ChanneReaderIAsyncEnumerable.cs +++ b/src/NexNet.IntegrationTests/Pipes/NexusServerTests_ChanneReaderIAsyncEnumerable.cs @@ -11,6 +11,7 @@ internal class NexusServerTests_ChanneReaderIAsyncEnumerable : BasePipeTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task IAsyncEnumerable(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -58,6 +59,7 @@ public async Task IAsyncEnumerable(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task StopsOnCompletion(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -89,6 +91,7 @@ public async Task StopsOnCompletion(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task CancelsRead(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -120,6 +123,7 @@ public async Task CancelsRead(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task CancelsAndResumesRead(Type type) { var (server, _, cNexus, _) = await Setup(type); diff --git a/src/NexNet.IntegrationTests/Pipes/NexusServerTests_NexusDuplexPipe.cs b/src/NexNet.IntegrationTests/Pipes/NexusServerTests_NexusDuplexPipe.cs index 46c46a2f..77b9d5a9 100644 --- a/src/NexNet.IntegrationTests/Pipes/NexusServerTests_NexusDuplexPipe.cs +++ b/src/NexNet.IntegrationTests/Pipes/NexusServerTests_NexusDuplexPipe.cs @@ -13,6 +13,7 @@ internal class NexusServerTests_NexusDuplexPipe : BasePipeTests [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeReaderReceivesDataMultipleTimes(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -50,6 +51,7 @@ public async Task Server_PipeReaderReceivesDataMultipleTimes(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeReaderReceivesDataMultipleTimesWithLargeData(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -91,6 +93,7 @@ public async Task Server_PipeReaderReceivesDataMultipleTimesWithLargeData(Type t [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeReaderCreatesAndDestroysPipeMultipleTimes(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -122,6 +125,7 @@ public async Task Server_PipeReaderCreatesAndDestroysPipeMultipleTimes(Type type [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeReaderReceivesData(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -147,6 +151,7 @@ public async Task Server_PipeReaderReceivesData(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeWriterSendsData(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -170,6 +175,7 @@ public async Task Server_PipeWriterSendsData(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeReaderCompletesUponCompleteAsync(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -196,6 +202,7 @@ public async Task Server_PipeReaderCompletesUponCompleteAsync(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeWriterCompletesUponCompleteAsync(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -237,6 +244,7 @@ public async Task Server_PipeWriterCompletesUponCompleteAsync(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeReaderCompletesUponDisconnection(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -264,6 +272,7 @@ public async Task Server_PipeReaderCompletesUponDisconnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeWriterCompletesUponDisconnection(Type type) { var tcsDisconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -296,6 +305,7 @@ await cNexus.Context.Proxy.ServerTaskValueWithDuplexPipe(pipe).AsTask() [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeReaderCompletesUponWriterCompletion(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -323,6 +333,7 @@ public async Task Server_PipeReaderCompletesUponWriterCompletion(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeWriterCompletesUponWriterCompletion(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -357,6 +368,7 @@ public async Task Server_PipeWriterCompletesUponWriterCompletion(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeWriterRemainsOpenUponOtherWriterCompletion(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -387,6 +399,7 @@ public async Task Server_PipeWriterRemainsOpenUponOtherWriterCompletion(Type typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeReaderRemainsOpenUponOtherReaderCompletion(Type type) { var (server, _, cNexus, tcs) = await Setup(type); @@ -426,6 +439,7 @@ public async Task Server_PipeReaderRemainsOpenUponOtherReaderCompletion(Type typ [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeNotifiesWhenReady(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -449,6 +463,7 @@ public async Task Server_PipeNotifiesWhenReady(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeReadyCancelsOnDisconnection(Type type) { var (server, client, _, _) = await Setup(type); @@ -474,6 +489,7 @@ public async Task Server_PipeReadyCancelsOnDisconnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeCompleteCancelsOnDisconnection(Type type) { var (server, client, _, _) = await Setup(type); @@ -500,6 +516,7 @@ public async Task Server_PipeCompleteCancelsOnDisconnection(Type type) [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_PipeNotifiesWhenComplete(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -567,6 +584,7 @@ public async Task Server_PipesAllowInvocationOnSingleConnections() [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_ThrowsWhenPassingPipeFromWrongNexus(Type type) { var (server, _, cNexus, _) = await Setup(type); @@ -586,6 +604,7 @@ await AssertThrows(() => [TestCase(Type.TcpTls)] [TestCase(Type.WebSocket)] [TestCase(Type.HttpSocket)] + [TestCase(Type.InProcess)] public async Task Server_ThrowsWhenPassingUsedPipe(Type type) { var (server, _, cNexus, _) = await Setup(type); diff --git a/src/NexNet.IntegrationTests/SessionManagement/MockNexusSession.cs b/src/NexNet.IntegrationTests/SessionManagement/MockNexusSession.cs index dafbcdda..bae74aed 100644 --- a/src/NexNet.IntegrationTests/SessionManagement/MockNexusSession.cs +++ b/src/NexNet.IntegrationTests/SessionManagement/MockNexusSession.cs @@ -74,6 +74,7 @@ public Task DisconnectAsync(DisconnectReason reason, [CallerFilePath] string? fi public PoolManager PoolManager { get; set; } = null!; public NexusCollectionManager CollectionManager { get; set; } = null!; public ConfigBase Config { get; set; } = null!; + public IPipeFactory? PipeFactory { get; set; } public bool IsServer { get; set; } = true; public NexusPipeManager PipeManager { get; set; } = null!; public string? RemoteAddress { get; set; } = "127.0.0.1"; @@ -119,4 +120,6 @@ public void CancelAll() { // No-op for testing } + + public int PendingInvocationCount => 0; } diff --git a/src/NexNet.NuGet.targets b/src/NexNet.NuGet.targets index 7c1569c4..a7b93888 100644 --- a/src/NexNet.NuGet.targets +++ b/src/NexNet.NuGet.targets @@ -20,8 +20,11 @@ $(IntermediateOutputPath)README.nuget.md + + $(MSBuildThisFileDirectory)../README.md - + <_PackageFiles Include="$(NuGetReadmePath)" PackagePath="/README.md" /> diff --git a/src/NexNet.Testing.Tests/AssertionTests.cs b/src/NexNet.Testing.Tests/AssertionTests.cs new file mode 100644 index 00000000..b444ba5f --- /dev/null +++ b/src/NexNet.Testing.Tests/AssertionTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Threading.Tasks; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +internal class AssertionTests +{ + [SetUp] + public void ResetEditorState() => EditorServerNexus.ResetAll(); + + private async Task<(NexusTestHost host, + NexusTestClient client, + EditorServerNexus server)> + SetupAsync() + { + var sNexus = new EditorServerNexus(); + var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>( + () => sNexus, + () => new EditorClientNexus()); + var client = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + return (host, client, sNexus); + } + + [Test] + public async Task AssertReceived_PassesAfterInvocation() + { + var (host, client, _) = await SetupAsync(); + await using var _host = host; + + await client.Server.Ping(42); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + host.AssertReceived(n => n.Ping(42)); + } + + [Test] + public async Task AssertReceived_WildcardArg() + { + var (host, client, _) = await SetupAsync(); + await using var _host = host; + + await client.Server.Ping(99); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + host.AssertReceived(n => n.Ping(Arg.Any())); + } + + [Test] + public async Task AssertReceived_PredicateArg() + { + var (host, client, _) = await SetupAsync(); + await using var _host = host; + + await client.Server.Ping(150); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + host.AssertReceived(n => n.Ping(Arg.Is(x => x > 100))); + } + + [Test] + public async Task AssertReceived_TimesMismatch_Throws() + { + var (host, client, _) = await SetupAsync(); + await using var _host = host; + + await client.Server.Ping(1); + await client.Server.Ping(2); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.Throws( + () => host.AssertReceived(n => n.Ping(Arg.Any()), times: 5)); + } + + [Test] + public async Task AssertNotReceived_PassesWhenAbsent() + { + var (host, _, _) = await SetupAsync(); + await using var _host = host; + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + host.AssertNotReceived(n => n.Ping(Arg.Any())); + } + + [Test] + public async Task AssertNotReceived_ThrowsWhenPresent() + { + var (host, client, _) = await SetupAsync(); + await using var _host = host; + + await client.Server.Ping(7); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.Throws( + () => host.AssertNotReceived(n => n.Ping(Arg.Any()))); + } + + [Test] + public async Task WaitFor_CompletesOnArrival() + { + var (host, client, _) = await SetupAsync(); + await using var _host = host; + + var waiter = host.WaitFor(n => n.Ping(Arg.Any()), TimeSpan.FromSeconds(2)); + await client.Server.Ping(5); + await waiter; + } + + [Test] + public async Task WaitFor_TimesOut() + { + var (host, _, _) = await SetupAsync(); + await using var _host = host; + + Assert.ThrowsAsync(async () => + await host.WaitFor(n => n.Ping(Arg.Any()), TimeSpan.FromMilliseconds(200))); + } + + [Test] + public async Task AssertReceived_StringArg_MatchesExactly() + { + var (host, client, _) = await SetupAsync(); + await using var _host = host; + + await client.Server.Notify("hello"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + host.AssertReceived(n => n.Notify("hello")); + host.AssertNotReceived(n => n.Notify("goodbye")); + } + + [Test] + public async Task AssertReceived_MultiArg_AllMustMatch() + { + var (host, client, _) = await SetupAsync(); + await using var _host = host; + + await client.Server.SaveDraft("design.md", "v1"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + host.AssertReceived(n => n.SaveDraft("design.md", "v1")); + host.AssertReceived(n => n.SaveDraft("design.md", Arg.Any())); + host.AssertReceived(n => n.SaveDraft(Arg.Any(), Arg.Any())); + host.AssertNotReceived(n => n.SaveDraft("design.md", "v2")); + host.AssertNotReceived(n => n.SaveDraft("recipe.txt", "v1")); + } + + [Test] + public async Task AssertReceived_MismatchDiagnostic_IncludesRecordedArgs() + { + var (host, client, _) = await SetupAsync(); + await using var _host = host; + + await client.Server.Notify("alpha"); + await client.Server.Notify("beta"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + var ex = Assert.Throws( + () => host.AssertReceived(n => n.Notify("gamma"))); + + // The diagnostic must include the actual recorded arg values, not just method ids. + Assert.That(ex!.Message, Does.Contain("alpha")); + Assert.That(ex.Message, Does.Contain("beta")); + Assert.That(ex.Message, Does.Contain("Notify")); + } +} diff --git a/src/NexNet.Testing.Tests/ChannelRecordingTests.cs b/src/NexNet.Testing.Tests/ChannelRecordingTests.cs new file mode 100644 index 00000000..5094c0c6 --- /dev/null +++ b/src/NexNet.Testing.Tests/ChannelRecordingTests.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Testing.Streaming; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +internal class ChannelRecordingTests +{ + [Test] + public void RecordItem_AppendsToItems() + { + var rec = new ChannelRecording(); + rec.RecordItem(1); + rec.RecordItem(2); + Assert.That(rec.Items, Is.EqualTo(new[] { 1, 2 })); + } + + [Test] + public async Task WaitForCountAsync_CompletesAtThreshold() + { + var rec = new ChannelRecording(); + var task = rec.WaitForCountAsync(3); + Assert.That(task.IsCompleted, Is.False); + + rec.RecordItem(1); + rec.RecordItem(2); + Assert.That(task.IsCompleted, Is.False); + + rec.RecordItem(3); + await task.WaitAsync(TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task Completion_NotifiesWaiter() + { + var rec = new ChannelRecording(); + var waiter = rec.WaitForCompletionAsync(); + rec.RecordCompletion(null); + await waiter.WaitAsync(TimeSpan.FromSeconds(1)); + Assert.That(rec.IsCompleted, Is.True); + } + + [Test] + public void Completion_CarriesException() + { + var rec = new ChannelRecording(); + var error = new InvalidOperationException("boom"); + rec.RecordCompletion(error); + Assert.That(rec.FaultedWith, Is.SameAs(error)); + } +} diff --git a/src/NexNet.Testing.Tests/ClientAssertionTests.cs b/src/NexNet.Testing.Tests/ClientAssertionTests.cs new file mode 100644 index 00000000..49ae2969 --- /dev/null +++ b/src/NexNet.Testing.Tests/ClientAssertionTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Threading.Tasks; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +internal class ClientAssertionTests +{ + private static Task> CreateHost() + => NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>(); + + [SetUp] + public void ResetEditorState() => EditorServerNexus.ResetAll(); + + [Test] + public async Task AssertReceived_OnSpecificClient_Succeeds() + { + await using var host = await CreateHost(); + var c1 = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + var c2 = await host.ConnectAsAsync(TestIdentity.Of("bob", "Write")); + + await c1.Server.OpenDocument("design.md"); + await c2.Server.OpenDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + // SaveDraft uses Group (not GroupExceptCaller), so both c1 and c2 receive DraftSaved. + await c1.Server.SaveDraft("design.md", "v1"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + c1.AssertReceived(n => n.DraftSaved("alice", "v1")); + c2.AssertReceived(n => n.DraftSaved("alice", "v1")); + } + + [Test] + public async Task AssertNotReceived_OnNonMember_Succeeds() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + var outsider = await host.ConnectAsAsync(TestIdentity.Of("outsider", "Write")); + + await alice.Server.OpenDocument("design.md"); + // outsider intentionally not in the design.md group + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + await alice.Server.SaveDraft("design.md", "members-only"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + alice.AssertReceived(n => n.DraftSaved("alice", "members-only")); + outsider.AssertNotReceived(n => n.DraftSaved(Arg.Any(), Arg.Any())); + } + + [Test] + public async Task AssertReceived_TimesMismatch_Throws() + { + await using var host = await CreateHost(); + var c1 = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + await c1.Server.OpenDocument("design.md"); + await c1.Server.SaveDraft("design.md", "once"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.Throws( + () => c1.AssertReceived(n => n.DraftSaved("alice", "once"), times: 2)); + } + + [Test] + public async Task WaitFor_OnClient_CompletesWhenMatchArrives() + { + await using var host = await CreateHost(); + var c1 = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + await c1.Server.OpenDocument("design.md"); + + // Start WaitFor BEFORE the broadcast so it has to wait for arrival. + var wait = c1.WaitFor(n => n.DraftSaved(Arg.Any(), Arg.Any()), TimeSpan.FromSeconds(3)); + await c1.Server.SaveDraft("design.md", "delayed"); + await wait; // should complete within timeout + } + + [Test] + public async Task WaitFor_Timeout_Throws() + { + await using var host = await CreateHost(); + var c1 = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + + Assert.ThrowsAsync(async () => + await c1.WaitFor( + n => n.DraftSaved("never", "never"), + TimeSpan.FromMilliseconds(200))); + } +} diff --git a/src/NexNet.Testing.Tests/EditorAppNexus.cs b/src/NexNet.Testing.Tests/EditorAppNexus.cs new file mode 100644 index 00000000..386f3626 --- /dev/null +++ b/src/NexNet.Testing.Tests/EditorAppNexus.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MemoryPack; +using NexNet.Invocation; +using NexNet.Pipes; + +namespace NexNet.Testing.Tests; + +public enum DocPermission +{ + Read, + Write, + Admin, +} + +[MemoryPackable] +public partial record struct EditOp(int Position, string Inserted); + +internal sealed class DocState +{ + public byte[]? Attachment; + public ConcurrentQueue Edits { get; } = new(); +} + +[Nexus(NexusType = NexusType.Server)] +internal partial class EditorServerNexus : ServerNexusBase, IEditorServerNexus +{ + public int PingCount; + + // Cross-session shared state. Tests MUST clear via ResetAll() at the top of each [Test]. + public static readonly ConcurrentDictionary Documents = new(); + public static readonly ConcurrentDictionary> ActiveEditors = new(); + public static byte[]? LastUploadedBytes; + public static List? LastCollectedItems; + + // Resets cross-fixture process-global state. PingCount is intentionally NOT touched — + // it is per-instance (each test gets its own EditorServerNexus via the factory) and + // doesn't bleed across tests. The Documents / ActiveEditors / LastUploadedBytes / + // LastCollectedItems statics persist across nexus instances and MUST be cleared by + // every test fixture that exercises these fields, regardless of whether the test + // reads them directly. Tests assume sequential fixture execution; enabling + // [assembly: Parallelizable(ParallelScope.Fixtures)] would race this state. + public static void ResetAll() + { + Documents.Clear(); + ActiveEditors.Clear(); + LastUploadedBytes = null; + LastCollectedItems = null; + } + + public ValueTask Ping(int value) + { + Interlocked.Increment(ref PingCount); + return ValueTask.FromResult(value); + } + + public ValueTask Notify(string message) => ValueTask.CompletedTask; + + public async ValueTask Upload(INexusDuplexPipe pipe) + { + using var ms = new MemoryStream(); + while (true) + { + var result = await pipe.Input.ReadAsync(); + foreach (var segment in result.Buffer) + ms.Write(segment.Span); + pipe.Input.AdvanceTo(result.Buffer.End); + if (result.IsCompleted) break; + } + LastUploadedBytes = ms.ToArray(); + } + + public async ValueTask Download(INexusDuplexPipe pipe, int byteCount) + { + var buf = new byte[byteCount]; + for (int i = 0; i < byteCount; i++) buf[i] = (byte)i; + await pipe.Output.WriteAsync(buf); + await pipe.CompleteAsync(); + } + + public async ValueTask CollectStrings(INexusDuplexPipe pipe) + { + var reader = await pipe.GetChannelReader(); + var items = new List(); + await foreach (var item in reader) + items.Add(item); + LastCollectedItems = items; + } + + public async ValueTask PublishStrings(INexusDuplexPipe pipe, string[] items) + { + var writer = await pipe.GetChannelWriter(); + foreach (var item in items) + await writer.WriteAsync(item); + await writer.CompleteAsync(); + } + + public async ValueTask OpenDocument(string docId) + { + var name = Context.Identity?.DisplayName ?? "anonymous"; + await Context.Groups.AddAsync(GroupName(docId)); + var registry = ActiveEditors.GetOrAdd(docId, _ => new ConcurrentDictionary()); + registry[Context.Id] = name; + Documents.GetOrAdd(docId, _ => new DocState()); + await Context.Clients.GroupExceptCaller(GroupName(docId)).EditorJoined(name); + } + + public async ValueTask LeaveDocument(string docId) + { + var name = Context.Identity?.DisplayName ?? "anonymous"; + await Context.Clients.GroupExceptCaller(GroupName(docId)).EditorLeft(name); + await Context.Groups.RemoveAsync(GroupName(docId)); + if (ActiveEditors.TryGetValue(docId, out var registry)) + registry.TryRemove(Context.Id, out _); + } + + [NexusAuthorize(DocPermission.Write)] + public async ValueTask SaveDraft(string docId, string content) + { + var name = Context.Identity?.DisplayName ?? "anonymous"; + await Context.Clients.Group(GroupName(docId)).DraftSaved(name, content); + } + + public async ValueTask Whisper(long targetSessionId, string text) + { + var name = Context.Identity?.DisplayName ?? "anonymous"; + await Context.Clients.Client(targetSessionId).WhisperReceived(name, text); + } + + [NexusAuthorize(DocPermission.Admin)] + public async ValueTask BroadcastSystemAnnouncement(string message) + { + await Context.Clients.All.SystemAnnouncement(message); + } + + public async ValueTask ListActiveEditors(string docId, CancellationToken cancellationToken) + { + // Observable async point so callers passing a cancellable CancellationToken can + // actually observe cancellation. The framework only sends a cancel signal when the + // client-side CT FIRES during the call (pre-cancelled tokens are not short-circuited + // by the proxy), so the server needs an awaiting point long enough for that signal + // to arrive. Guarded on CanBeCanceled so happy-path callers (CancellationToken.None) + // don't pay the latency tax. + if (cancellationToken.CanBeCanceled) + await Task.Delay(200, cancellationToken); + if (!ActiveEditors.TryGetValue(docId, out var registry)) + return Array.Empty(); + return registry.Values.ToArray(); + } + + public async ValueTask UploadAttachment(string docId, INexusDuplexPipe pipe) + { + var doc = Documents.GetOrAdd(docId, _ => new DocState()); + using var ms = new MemoryStream(); + while (true) + { + var result = await pipe.Input.ReadAsync(); + foreach (var segment in result.Buffer) + ms.Write(segment.Span); + pipe.Input.AdvanceTo(result.Buffer.End); + if (result.IsCompleted) break; + } + doc.Attachment = ms.ToArray(); + } + + public async ValueTask StreamEdits(string docId, INexusDuplexChannel channel) + { + var doc = Documents.GetOrAdd(docId, _ => new DocState()); + var reader = await channel.GetReaderAsync(); + await foreach (var op in reader) + doc.Edits.Enqueue(op); + } + + // Policy: AND-semantics over requiredPermissions — every declared permission must be + // present on the identity, otherwise Unauthorized. This is a per-app choice, not a + // framework default — the framework hands you the int[] of required permissions and + // OnAuthorize decides how to interpret them. An OR-semantics policy would just flip the + // loop to "any match → Allowed". Marker-only [NexusAuthorize()] (empty + // requiredPermissions) is currently treated as "TestIdentity sufficient" since the for + // loop is skipped entirely. + protected override ValueTask OnAuthorize( + ServerSessionContext context, + int methodId, + string methodName, + ReadOnlyMemory requiredPermissions) + { + if (context.Identity is not TestIdentity id) + return new ValueTask(AuthorizeResult.Unauthorized); + var span = requiredPermissions.Span; + for (int i = 0; i < span.Length; i++) + { + var roleName = ((DocPermission)span[i]).ToString(); + if (!id.IsInRole(roleName)) + return new ValueTask(AuthorizeResult.Unauthorized); + } + return new ValueTask(AuthorizeResult.Allowed); + } + + private static string GroupName(string docId) => $"doc-{docId}"; +} + +[Nexus(NexusType = NexusType.Client)] +internal partial class EditorClientNexus : ClientNexusBase, IEditorClientNexus +{ + public ValueTask DraftSaved(string author, string content) => ValueTask.CompletedTask; + public ValueTask EditorJoined(string author) => ValueTask.CompletedTask; + public ValueTask EditorLeft(string author) => ValueTask.CompletedTask; + public ValueTask WhisperReceived(string from, string text) => ValueTask.CompletedTask; + public ValueTask SystemAnnouncement(string message) => ValueTask.CompletedTask; +} + +internal partial interface IEditorServerNexus +{ + ValueTask Ping(int value); + ValueTask Notify(string message); + ValueTask Upload(INexusDuplexPipe pipe); + ValueTask Download(INexusDuplexPipe pipe, int byteCount); + ValueTask CollectStrings(INexusDuplexPipe pipe); + ValueTask PublishStrings(INexusDuplexPipe pipe, string[] items); + ValueTask OpenDocument(string docId); + ValueTask LeaveDocument(string docId); + ValueTask SaveDraft(string docId, string content); + ValueTask Whisper(long targetSessionId, string text); + ValueTask BroadcastSystemAnnouncement(string message); + ValueTask ListActiveEditors(string docId, CancellationToken cancellationToken); + ValueTask UploadAttachment(string docId, INexusDuplexPipe pipe); + ValueTask StreamEdits(string docId, INexusDuplexChannel channel); +} + +internal partial interface IEditorClientNexus +{ + ValueTask DraftSaved(string author, string content); + ValueTask EditorJoined(string author); + ValueTask EditorLeft(string author); + ValueTask WhisperReceived(string from, string text); + ValueTask SystemAnnouncement(string message); +} diff --git a/src/NexNet.Testing.Tests/EditorAppShowcaseTests.cs b/src/NexNet.Testing.Tests/EditorAppShowcaseTests.cs new file mode 100644 index 00000000..deed42fa --- /dev/null +++ b/src/NexNet.Testing.Tests/EditorAppShowcaseTests.cs @@ -0,0 +1,419 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Invocation; +using NexNet.Testing.Streaming; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +/// +/// End-to-end showcase of the NexNet.Testing harness driven through the editor-app demo +/// nexus. Each test exercises one observable harness capability via a natural business +/// verb on rather than synthetic passthrough methods. +/// +internal class EditorAppShowcaseTests +{ + private static Task> CreateHost() + => NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>(); + + [SetUp] + public void ResetEditorState() => EditorServerNexus.ResetAll(); + + [Test] + public async Task Groups_EmptyGroup_HasNoMembers() + { + await using var host = await CreateHost(); + Assert.That(host.Groups["doc-design.md"].Members, Is.Empty); + Assert.That(host.Groups["doc-design.md"].Count, Is.EqualTo(0)); + } + + [Test] + public async Task SaveDraft_BroadcastsToDocGroup_WithAuthorIdentity() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob", "Write")); + var carol = await host.ConnectAsAsync(TestIdentity.Of("carol", "Write")); + + await alice.Server.OpenDocument("design.md"); + await bob.Server.OpenDocument("design.md"); + await carol.Server.OpenDocument("recipe.txt"); + + await alice.Server.SaveDraft("design.md", "v1"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + // SaveDraft uses Group (caller-inclusive) → alice + bob receive DraftSaved. + alice.AssertReceived(n => n.DraftSaved("alice", "v1")); + bob.AssertReceived(n => n.DraftSaved("alice", "v1")); + carol.AssertNotReceived( + n => n.DraftSaved(Arg.Any(), Arg.Any())); + } + + [Test] + public async Task LeaveDocument_NotifiesOthersExceptCaller() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob")); + var carol = await host.ConnectAsAsync(TestIdentity.Of("carol")); + + await alice.Server.OpenDocument("design.md"); + await bob.Server.OpenDocument("design.md"); + await carol.Server.OpenDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + await bob.Server.LeaveDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + // GroupExceptCaller → alice + carol see bob leave; bob does not see his own EditorLeft. + alice.AssertReceived(n => n.EditorLeft("bob")); + carol.AssertReceived(n => n.EditorLeft("bob")); + bob.AssertNotReceived(n => n.EditorLeft("bob")); + } + + [Test] + public async Task Whisper_DeliveredToTargetOnly() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob")); + var carol = await host.ConnectAsAsync(TestIdentity.Of("carol")); + + await alice.Server.Whisper(bob.Nexus.Context.Id, "hi-bob"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + bob.AssertReceived(n => n.WhisperReceived("alice", "hi-bob")); + carol.AssertNotReceived( + n => n.WhisperReceived(Arg.Any(), Arg.Any())); + alice.AssertNotReceived( + n => n.WhisperReceived(Arg.Any(), Arg.Any())); + } + + [Test] + public async Task BroadcastSystemAnnouncement_AsNonAdmin_Throws() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + + Assert.ThrowsAsync( + async () => await alice.Server.BroadcastSystemAnnouncement("hello")); + } + + [Test] + public async Task BroadcastSystemAnnouncement_AsAdmin_DeliversToAll() + { + await using var host = await CreateHost(); + var admin = await host.ConnectAsAsync(TestIdentity.Of("root", "Admin")); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob", "Read")); + + await admin.Server.BroadcastSystemAnnouncement("server-restart-in-5"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + admin.AssertReceived(n => n.SystemAnnouncement("server-restart-in-5")); + alice.AssertReceived(n => n.SystemAnnouncement("server-restart-in-5")); + bob.AssertReceived(n => n.SystemAnnouncement("server-restart-in-5")); + } + + [Test] + public async Task SaveDraft_AsReader_Throws() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Read")); + + Assert.ThrowsAsync( + async () => await alice.Server.SaveDraft("design.md", "v1")); + } + + [Test] + public async Task ListActiveEditors_ReturnsNames() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob")); + var carol = await host.ConnectAsAsync(TestIdentity.Of("carol")); + + await alice.Server.OpenDocument("design.md"); + await bob.Server.OpenDocument("design.md"); + await carol.Server.OpenDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + var names = await alice.Server.ListActiveEditors("design.md", CancellationToken.None); + + Assert.That(names, Is.EquivalentTo(new[] { "alice", "bob", "carol" })); + } + + [Test] + public async Task ListActiveEditors_CancelledToken_PropagatesAndThrows() + { + // Client-side CT fires partway through the server's `await Task.Delay(200, ct)` in + // ListActiveEditors (the delay is guarded on CanBeCanceled, so only cancellable + // callers pay it). Framework propagates the cancel signal to the server-side CT, + // which causes the delay to throw. Pre-cancelled tokens are not short-circuited by + // the proxy — the cancel signal is only sent when the client-side CT FIRES. The + // assertion uses OperationCanceledException (the base) rather than TaskCanceledException + // so the test pins the semantic, not the concrete subtype. + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(10)); + + Assert.That( + async () => await alice.Server.ListActiveEditors("design.md", cts.Token), + Throws.InstanceOf()); + } + + [Test] + public async Task UploadAttachment_StreamsBytes_ServerAppendsToDoc() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + var payload = new byte[4096]; + for (int i = 0; i < payload.Length; i++) payload[i] = (byte)(i & 0xFF); + + await using var pipe = alice.CreatePipe(); + var serverCall = alice.Server.UploadAttachment("design.md", pipe).AsTask(); + await pipe.PipeUploadAsync(payload); + await serverCall.WaitAsync(TimeSpan.FromSeconds(2)); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(EditorServerNexus.Documents.TryGetValue("design.md", out var doc), Is.True); + Assert.That(doc!.Attachment, Is.EqualTo(payload)); + } + + [Test] + public async Task StreamEdits_AllOpsCollected() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + var ops = Enumerable.Range(0, 10) + .Select(i => new EditOp(i, $"text-{i}")) + .ToArray(); + + await using var channel = alice.Nexus.Context.CreateChannel(); + var serverCall = alice.Server.StreamEdits("design.md", channel).AsTask(); + var writer = await channel.GetWriterAsync(); + foreach (var op in ops) + await writer.WriteAsync(op); + await writer.CompleteAsync(); + await serverCall.WaitAsync(TimeSpan.FromSeconds(2)); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(EditorServerNexus.Documents.TryGetValue("design.md", out var doc), Is.True); + Assert.That(doc!.Edits.ToArray(), Is.EqualTo(ops)); + } + + [Test] + public async Task WaitFor_DraftSaved_ResolvesWhenInvoked() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob", "Write")); + + await alice.Server.OpenDocument("design.md"); + await bob.Server.OpenDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + // Producer runs in the background AFTER a small delay so the waiter actually + // observes the async-arrival path, not the "already recorded" fast path. + var waiter = alice.WaitFor( + n => n.DraftSaved("bob", "v1"), TimeSpan.FromSeconds(3)); + var producer = Task.Run(async () => + { + await Task.Delay(100); + await bob.Server.SaveDraft("design.md", "v1"); + }); + await waiter; + await producer; + } + + [Test] + public async Task WaitFor_Timeout_Throws() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + Assert.ThrowsAsync(async () => + await alice.WaitFor( + n => n.DraftSaved("never", "never"), + TimeSpan.FromMilliseconds(250))); + } + + [Test] + public async Task MixedTraffic_QuiesceAsync_WaitsForEverything() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob", "Write")); + var carol = await host.ConnectAsAsync(TestIdentity.Of("carol", "Write")); + + await alice.Server.OpenDocument("design.md"); + await bob.Server.OpenDocument("design.md"); + await carol.Server.OpenDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + var attachment = new byte[256]; + for (int i = 0; i < attachment.Length; i++) attachment[i] = (byte)(i & 0xFF); + var ops = Enumerable.Range(0, 5).Select(i => new EditOp(i, $"o{i}")).ToArray(); + + // Fire all four traffic shapes in flight at once. + await using var pipe = alice.CreatePipe(); + await using var channel = bob.Nexus.Context.CreateChannel(); + + var draftCall = alice.Server.SaveDraft("design.md", "draft-a").AsTask(); + var whisperCall = carol.Server.Whisper(bob.Nexus.Context.Id, "ping").AsTask(); + + var uploadCall = alice.Server.UploadAttachment("design.md", pipe).AsTask(); + var uploadDrive = pipe.PipeUploadAsync(attachment).AsTask(); + + var streamCall = bob.Server.StreamEdits("design.md", channel).AsTask(); + var streamDrive = Task.Run(async () => + { + var writer = await channel.GetWriterAsync(); + foreach (var op in ops) await writer.WriteAsync(op); + await writer.CompleteAsync(); + }); + + await Task.WhenAll(draftCall, whisperCall, uploadDrive, uploadCall, streamDrive, streamCall) + .WaitAsync(TimeSpan.FromSeconds(5)); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + // Every callback delivered exactly once on the expected recipients; non-recipients + // never see the per-target whisper. + alice.AssertReceived(n => n.DraftSaved("alice", "draft-a"), times: 1); + bob.AssertReceived(n => n.DraftSaved("alice", "draft-a"), times: 1); + carol.AssertReceived(n => n.DraftSaved("alice", "draft-a"), times: 1); + bob.AssertReceived(n => n.WhisperReceived("carol", "ping"), times: 1); + alice.AssertNotReceived( + n => n.WhisperReceived(Arg.Any(), Arg.Any())); + carol.AssertNotReceived( + n => n.WhisperReceived(Arg.Any(), Arg.Any())); + Assert.That(EditorServerNexus.Documents["design.md"].Attachment, Is.EqualTo(attachment)); + Assert.That(EditorServerNexus.Documents["design.md"].Edits.ToArray(), Is.EqualTo(ops)); + } + + [Test] + public async Task OpenDocument_NotifiesOthersExceptCaller() + { + // Pairs with LeaveDocument_NotifiesOthersExceptCaller — exercises the EditorJoined + // GroupExceptCaller broadcast that fires inside OpenDocument. + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob")); + var carol = await host.ConnectAsAsync(TestIdentity.Of("carol")); + + await alice.Server.OpenDocument("design.md"); + await bob.Server.OpenDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + // Carol joins last → alice + bob receive EditorJoined("carol"); carol does not see + // her own join. Alice never sees her own join either (first joiner, group empty at + // broadcast time). + await carol.Server.OpenDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + alice.AssertReceived(n => n.EditorJoined("carol"), times: 1); + bob.AssertReceived(n => n.EditorJoined("carol"), times: 1); + carol.AssertNotReceived(n => n.EditorJoined("carol")); + alice.AssertNotReceived(n => n.EditorJoined("alice")); + } + + [Test] + public async Task Connect_WithoutIdentity_IsRejectedAtHandshake() + { + // The anonymous-call-to-gated-method path can't be exercised directly because the + // framework rejects null-identity sessions at the handshake (NexusSession.Receiving + // returns DisconnectReason.Authentication when Authenticate returns null). The + // TestAuthenticationStore-backed override returns null for a missing token, so + // ConnectAsync() (no identity) never completes a usable session and the OnAuthorize + // override's `is not TestIdentity` guard isn't reachable via this harness's auth path. + await using var host = await CreateHost(); + + Assert.That( + async () => await host.ConnectAsync(), + Throws.InstanceOf() + .With.Message.Contains("Authentication")); + } + + [Test] + public async Task Groups_Introspection_ReflectsLiveMembership() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob")); + var carol = await host.ConnectAsAsync(TestIdentity.Of("carol")); + + await alice.Server.OpenDocument("design.md"); + await bob.Server.OpenDocument("design.md"); + await carol.Server.OpenDocument("recipe.txt"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + Assert.That(host.Groups["doc-design.md"].Count, Is.EqualTo(2)); + Assert.That(host.Groups["doc-recipe.txt"].Count, Is.EqualTo(1)); + + await bob.Server.LeaveDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + Assert.That(host.Groups["doc-design.md"].Count, Is.EqualTo(1)); + + await carol.Server.OpenDocument("design.md"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + Assert.That(host.Groups["doc-design.md"].Count, Is.EqualTo(2)); + } + + [Test] + public async Task ServerSide_AssertReceived_SaveDraft() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + + await alice.Server.OpenDocument("design.md"); + await alice.Server.SaveDraft("design.md", "v1"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + host.AssertReceived(n => n.SaveDraft("design.md", "v1")); + host.AssertReceived(n => n.OpenDocument("design.md")); + } + + [Test] + public async Task AssertReceived_TimesMismatch_DiagnosticIncludesArgs() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + + await alice.Server.OpenDocument("design.md"); + await alice.Server.SaveDraft("design.md", "v1"); + await alice.Server.SaveDraft("design.md", "v2"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + var ex = Assert.Throws( + () => host.AssertReceived(n => n.SaveDraft("design.md", "v3"))); + + Assert.That(ex!.Message, Does.Contain("v1")); + Assert.That(ex.Message, Does.Contain("v2")); + Assert.That(ex.Message, Does.Contain("SaveDraft")); + } + + [Test] + public async Task ArgMatchers_AnyAndPredicate() + { + await using var host = await CreateHost(); + var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); + var bob = await host.ConnectAsAsync(TestIdentity.Of("bob", "Write")); + + await alice.Server.OpenDocument("design.md"); + await bob.Server.OpenDocument("design.md"); + await alice.Server.SaveDraft("design.md", "v2-final"); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + bob.AssertReceived(n => n.DraftSaved("alice", Arg.Any())); + bob.AssertReceived(n => n.DraftSaved( + Arg.Any(), + Arg.Is(s => s.StartsWith("v")))); + } +} diff --git a/src/NexNet.Testing.Tests/ExpressionParserTests.cs b/src/NexNet.Testing.Tests/ExpressionParserTests.cs new file mode 100644 index 00000000..5fc38e0a --- /dev/null +++ b/src/NexNet.Testing.Tests/ExpressionParserTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Linq.Expressions; +using NexNet.Testing.Recording; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +internal class ExpressionParserTests +{ + public interface ISample + { + void Zero(); + void One(int id); + void Two(int id, string name); + void Mixed(int id, string name, bool flag); + int IntValue { get; } + } + + [Test] + public void ParsesNoArgMethod() + { + Expression> expr = n => n.Zero(); + var (method, matchers) = ExpressionParser.Parse(expr); + Assert.That(method.Name, Is.EqualTo("Zero")); + Assert.That(matchers, Is.Empty); + } + + [Test] + public void ParsesConstantArg() + { + Expression> expr = n => n.One(42); + var (_, matchers) = ExpressionParser.Parse(expr); + Assert.That(matchers, Has.Count.EqualTo(1)); + Assert.That(matchers[0].Matches(42), Is.True); + Assert.That(matchers[0].Matches(43), Is.False); + } + + [Test] + public void ParsesWildcardArg() + { + Expression> expr = n => n.One(Arg.Any()); + var (_, matchers) = ExpressionParser.Parse(expr); + Assert.That(matchers, Has.Count.EqualTo(1)); + Assert.That(matchers[0].Matches(0), Is.True); + Assert.That(matchers[0].Matches(int.MaxValue), Is.True); + Assert.That(matchers[0].ToString(), Does.Contain("Any")); + } + + [Test] + public void ParsesPredicateArg() + { + Expression> expr = n => n.One(Arg.Is(x => x > 100)); + var (_, matchers) = ExpressionParser.Parse(expr); + Assert.That(matchers[0].Matches(150), Is.True); + Assert.That(matchers[0].Matches(50), Is.False); + } + + [Test] + public void ParsesMixedArgs() + { + Expression> expr = n => + n.Mixed(7, Arg.Any(), Arg.Is(b => b)); + + var (method, matchers) = ExpressionParser.Parse(expr); + Assert.That(method.Name, Is.EqualTo("Mixed")); + Assert.That(matchers, Has.Count.EqualTo(3)); + + Assert.That(matchers[0].Matches(7), Is.True); + Assert.That(matchers[0].Matches(8), Is.False); + + Assert.That(matchers[1].Matches("anything"), Is.True); + Assert.That(matchers[1].Matches(null), Is.True); + + Assert.That(matchers[2].Matches(true), Is.True); + Assert.That(matchers[2].Matches(false), Is.False); + } + + [Test] + public void ParsesCapturedLocalAsEquality() + { + int expected = 99; + Expression> expr = n => n.One(expected); + + var (_, matchers) = ExpressionParser.Parse(expr); + Assert.That(matchers[0].Matches(99), Is.True); + Assert.That(matchers[0].Matches(100), Is.False); + } + + [Test] + public void StringEqualityMatcher() + { + Expression> expr = n => n.Two(1, "hello"); + var (_, matchers) = ExpressionParser.Parse(expr); + Assert.That(matchers[1].Matches("hello"), Is.True); + Assert.That(matchers[1].Matches("HELLO"), Is.False); + Assert.That(matchers[1].Matches(null), Is.False); + } + + [Test] + public void NonCallExpression_PropertyAccess_Throws() + { + // Real misuse: user wrote `n => n.IntValue` instead of `n => n.Method()`. The parser + // must surface this as a clear argument exception rather than producing an + // ill-formed matcher. + Expression> propertyAccess = n => n.IntValue; + // Coerce into the Action shape the parser accepts so it sees the property-access + // body, not the wrapper lambda's shape. + var asAction = Expression.Lambda>( + propertyAccess.Body, + propertyAccess.Parameters); + + Assert.Throws(() => ExpressionParser.Parse(asAction)); + } +} diff --git a/src/NexNet.Testing.Tests/InProcessTransportTests.cs b/src/NexNet.Testing.Tests/InProcessTransportTests.cs new file mode 100644 index 00000000..37498334 --- /dev/null +++ b/src/NexNet.Testing.Tests/InProcessTransportTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Testing.Transports.InProcess; +using NexNet.Transports; +using NUnit.Framework; + +namespace NexNet.Testing.Tests; + +/// +/// Isolation tests for and its rendezvous/listener pair. +/// Validates basic round-trip, ordering, and close semantics independently of the NexNet +/// protocol layer. +/// +internal class InProcessTransportTests +{ + [Test] + public async Task ServerAndClientTransports_ExchangeDataBidirectionally() + { + var endpoint = NewEndpoint(); + var serverConfig = new InProcessServerConfig { Endpoint = endpoint }; + var listener = await ((ServerConfigTestAccess)new ServerConfigTestAccess(serverConfig)) + .CreateListenerAsync(); + + var clientConfig = new InProcessClientConfig { Endpoint = endpoint }; + var clientTransport = await ((ClientConfigTestAccess)new ClientConfigTestAccess(clientConfig)) + .ConnectAsync(); + + var serverTransport = await listener.AcceptTransportAsync(CancellationToken.None); + Assert.That(serverTransport, Is.Not.Null); + + // Client -> Server + await clientTransport.Output.WriteAsync(Encoding.UTF8.GetBytes("hello-from-client")); + var fromClient = await ReadStringAsync(serverTransport!.Input, "hello-from-client".Length); + Assert.That(fromClient, Is.EqualTo("hello-from-client")); + + // Server -> Client + await serverTransport.Output.WriteAsync(Encoding.UTF8.GetBytes("hello-from-server")); + var fromServer = await ReadStringAsync(clientTransport.Input, "hello-from-server".Length); + Assert.That(fromServer, Is.EqualTo("hello-from-server")); + + await clientTransport.CloseAsync(true); + await serverTransport.CloseAsync(true); + await listener.CloseAsync(true); + } + + [Test] + public async Task SequentialMessages_PreserveOrdering() + { + var endpoint = NewEndpoint(); + var serverConfig = new InProcessServerConfig { Endpoint = endpoint }; + var listener = await ((ServerConfigTestAccess)new ServerConfigTestAccess(serverConfig)) + .CreateListenerAsync(); + + var clientConfig = new InProcessClientConfig { Endpoint = endpoint }; + var clientTransport = await ((ClientConfigTestAccess)new ClientConfigTestAccess(clientConfig)) + .ConnectAsync(); + + var serverTransport = await listener.AcceptTransportAsync(CancellationToken.None); + Assert.That(serverTransport, Is.Not.Null); + + for (int i = 0; i < 50; i++) + await clientTransport.Output.WriteAsync(new byte[] { (byte)i }); + + var bytes = await ReadBytesAsync(serverTransport!.Input, 50); + for (int i = 0; i < 50; i++) + Assert.That(bytes[i], Is.EqualTo((byte)i), $"position {i}"); + + await clientTransport.CloseAsync(true); + await serverTransport.CloseAsync(true); + await listener.CloseAsync(true); + } + + [Test] + public async Task ClientClose_CompletesPeerReader() + { + var endpoint = NewEndpoint(); + var serverConfig = new InProcessServerConfig { Endpoint = endpoint }; + var listener = await ((ServerConfigTestAccess)new ServerConfigTestAccess(serverConfig)) + .CreateListenerAsync(); + + var clientConfig = new InProcessClientConfig { Endpoint = endpoint }; + var clientTransport = await ((ClientConfigTestAccess)new ClientConfigTestAccess(clientConfig)) + .ConnectAsync(); + + var serverTransport = await listener.AcceptTransportAsync(CancellationToken.None); + Assert.That(serverTransport, Is.Not.Null); + + await clientTransport.CloseAsync(true); + + var read = await serverTransport!.Input.ReadAsync(); + Assert.That(read.IsCompleted, Is.True, "Peer reader must observe completion after close"); + + await serverTransport.CloseAsync(true); + await listener.CloseAsync(true); + } + + [Test] + public void ConnectWithoutListener_Throws() + { + var clientConfig = new InProcessClientConfig { Endpoint = NewEndpoint() }; + Assert.ThrowsAsync(async () => + await ((ClientConfigTestAccess)new ClientConfigTestAccess(clientConfig)).ConnectAsync()); + } + + [Test] + public async Task DuplicateEndpoint_SecondListenerRegistrationThrows() + { + var endpoint = NewEndpoint(); + var first = new InProcessServerConfig { Endpoint = endpoint }; + var firstListener = await ((ServerConfigTestAccess)new ServerConfigTestAccess(first)) + .CreateListenerAsync(); + + var second = new InProcessServerConfig { Endpoint = endpoint }; + Assert.ThrowsAsync(async () => + await ((ServerConfigTestAccess)new ServerConfigTestAccess(second)).CreateListenerAsync()); + + await firstListener.CloseAsync(true); + } + + private static string NewEndpoint() => $"test-{Guid.NewGuid():N}"; + + private static async Task ReadStringAsync(PipeReader reader, int byteCount) + { + var bytes = await ReadBytesAsync(reader, byteCount); + return Encoding.UTF8.GetString(bytes); + } + + private static async Task ReadBytesAsync(PipeReader reader, int byteCount) + { + var collected = new byte[byteCount]; + var collectedSpan = collected.AsMemory(); + var offset = 0; + while (offset < byteCount) + { + var read = await reader.ReadAsync(); + var buffer = read.Buffer; + var toCopy = (int)Math.Min(buffer.Length, byteCount - offset); + buffer.Slice(0, toCopy).CopyTo(collectedSpan.Slice(offset).Span); + offset += toCopy; + reader.AdvanceTo(buffer.GetPosition(toCopy)); + if (read.IsCompleted && offset < byteCount) + throw new InvalidOperationException($"Stream ended after {offset}/{byteCount} bytes"); + } + return collected; + } + + /// Test shim that invokes the protected OnCreateServerListener via reflection. + private sealed class ServerConfigTestAccess + { + private readonly InProcessServerConfig _config; + public ServerConfigTestAccess(InProcessServerConfig config) => _config = config; + + public async Task CreateListenerAsync() + { + var method = typeof(ServerConfig).GetMethod("OnCreateServerListener", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + ValueTask task; + try + { + task = (ValueTask)method.Invoke(_config, new object[] { CancellationToken.None })!; + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + throw; // unreachable + } + var listener = await task; + return listener!; + } + } + + private sealed class ClientConfigTestAccess + { + private readonly InProcessClientConfig _config; + public ClientConfigTestAccess(InProcessClientConfig config) => _config = config; + + public async Task ConnectAsync() + { + var method = typeof(ClientConfig).GetMethod("OnConnectTransport", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + ValueTask task; + try + { + task = (ValueTask)method.Invoke(_config, new object[] { CancellationToken.None })!; + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + throw; // unreachable + } + return await task; + } + } +} diff --git a/src/NexNet.Testing.Tests/Infrastructure/TestProgressReporter.cs b/src/NexNet.Testing.Tests/Infrastructure/TestProgressReporter.cs new file mode 100644 index 00000000..ac4ddbfe --- /dev/null +++ b/src/NexNet.Testing.Tests/Infrastructure/TestProgressReporter.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading; +using NUnit.Framework; +using NUnit.Framework.Interfaces; + +// Apply at assembly scope so the action wraps every test in the assembly without each fixture +// having to opt in. +[assembly: NexNet.Testing.Tests.Infrastructure.TestProgressReporter] + +namespace NexNet.Testing.Tests.Infrastructure; + +/// +/// Assembly-level that emits one progress line per N tests OR every +/// T seconds — whichever fires first. See the integration-tests copy of this class for full +/// rationale; this file is a duplicate per-assembly because NUnit assembly-level actions are +/// not transitively imported across project references. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +internal sealed class TestProgressReporterAttribute : Attribute, ITestAction +{ + private const int EveryNTests = 25; + private static readonly TimeSpan EveryInterval = TimeSpan.FromSeconds(10); + + private static int _ran; + private static int _failed; + private static long _startTicks; + private static long _lastEmitTicks; + private static readonly object _gate = new(); + + public ActionTargets Targets => ActionTargets.Test; + + public void BeforeTest(ITest test) + { + if (_startTicks == 0) + { + var now = Environment.TickCount64; + if (Interlocked.CompareExchange(ref _startTicks, now, 0) == 0) + Volatile.Write(ref _lastEmitTicks, now); + } + } + + public void AfterTest(ITest test) + { + var ran = Interlocked.Increment(ref _ran); + var outcome = TestContext.CurrentContext.Result.Outcome.Status; + if (outcome == TestStatus.Failed) + Interlocked.Increment(ref _failed); + + var nowTicks = Environment.TickCount64; + var elapsedSinceLastEmit = nowTicks - Volatile.Read(ref _lastEmitTicks); + var hitTestCadence = (ran % EveryNTests) == 0; + var hitTimeCadence = elapsedSinceLastEmit >= (long)EveryInterval.TotalMilliseconds; + + if (!hitTestCadence && !hitTimeCadence) + return; + + lock (_gate) + { + if (Volatile.Read(ref _lastEmitTicks) > nowTicks - 100) + return; + + var elapsed = TimeSpan.FromMilliseconds(nowTicks - Volatile.Read(ref _startTicks)); + var assemblyName = typeof(TestProgressReporterAttribute).Assembly.GetName().Name; + var failed = Volatile.Read(ref _failed); + var passed = ran - failed; + // Write to raw stderr so the dotnet test console logger doesn't filter it out at + // `verbosity=minimal` (TestContext.Progress is filtered too at that level). + Console.Error.WriteLine( + $"[progress] {assemblyName}: {ran} ran ({passed} passed, {failed} failed), {elapsed.TotalSeconds:F1}s elapsed"); + Volatile.Write(ref _lastEmitTicks, nowTicks); + } + } +} diff --git a/src/NexNet.Testing.Tests/InvocationRecorderTests.cs b/src/NexNet.Testing.Tests/InvocationRecorderTests.cs new file mode 100644 index 00000000..0aa261f0 --- /dev/null +++ b/src/NexNet.Testing.Tests/InvocationRecorderTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Testing.Recording; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +internal class InvocationRecorderTests +{ + [Test] + public void AppendIncreasesCount() + { + var recorder = new InvocationRecorder(); + Assert.That(recorder.Count, Is.Zero); + + recorder.Append(new InvocationRecord(1, ReadOnlyMemory.Empty, null)); + Assert.That(recorder.Count, Is.EqualTo(1)); + } + + [Test] + public void SnapshotReturnsStableCopy() + { + var recorder = new InvocationRecorder(); + recorder.Append(new InvocationRecord(1, ReadOnlyMemory.Empty, null)); + var first = recorder.Snapshot(); + + recorder.Append(new InvocationRecord(2, ReadOnlyMemory.Empty, null)); + Assert.That(first, Has.Count.EqualTo(1), "Earlier snapshot must not see later appends"); + Assert.That(recorder.Snapshot(), Has.Count.EqualTo(2)); + } + + [Test] + public async Task WaitForChangeAsync_CompletesOnAppend() + { + var recorder = new InvocationRecorder(); + var task = recorder.WaitForChangeAsync(CancellationToken.None); + Assert.That(task.IsCompleted, Is.False); + + recorder.Append(new InvocationRecord(1, ReadOnlyMemory.Empty, null)); + + await task.WaitAsync(TimeSpan.FromSeconds(1)); + Assert.That(task.IsCompleted, Is.True); + } + + [Test] + public async Task ConcurrentAppends_AreAllRecorded() + { + var recorder = new InvocationRecorder(); + const int writers = 8; + const int perWriter = 250; + + await Task.WhenAll(Enumerable_Range(0, writers).Select(w => Task.Run(() => + { + for (int i = 0; i < perWriter; i++) + recorder.Append(new InvocationRecord((ushort)w, ReadOnlyMemory.Empty, null)); + }))); + + Assert.That(recorder.Count, Is.EqualTo(writers * perWriter)); + } + + private static System.Collections.Generic.IEnumerable Enumerable_Range(int start, int count) + { + for (int i = 0; i < count; i++) yield return start + i; + } +} diff --git a/src/NexNet.Testing.Tests/MethodIdMapTests.cs b/src/NexNet.Testing.Tests/MethodIdMapTests.cs new file mode 100644 index 00000000..07b6d59b --- /dev/null +++ b/src/NexNet.Testing.Tests/MethodIdMapTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Linq; +using System.Reflection; +using NexNet.Testing.Recording; +using NUnit.Framework; + +namespace NexNet.Testing.Tests; + +/// +/// Regression coverage for . The map is the load-bearing piece +/// of the assertion API — if its output drifts from the generator-burned ids, asserts silently +/// match the wrong method (or none at all). These tests pin the expected ids for the demo +/// interfaces against the known source-declaration order. +/// +internal class MethodIdMapTests +{ + [Test] + public void GeneratorParity_EditorServerInterface_AssignsExpectedIds() + { + var map = MethodIdMap.Build(typeof(IEditorServerNexus)); + + // Source declaration order in EditorAppNexus.cs. + AssertMethodId(map, nameof(IEditorServerNexus.Ping), 0); + AssertMethodId(map, nameof(IEditorServerNexus.Notify), 1); + AssertMethodId(map, nameof(IEditorServerNexus.Upload), 2); + AssertMethodId(map, nameof(IEditorServerNexus.Download), 3); + AssertMethodId(map, nameof(IEditorServerNexus.CollectStrings), 4); + AssertMethodId(map, nameof(IEditorServerNexus.PublishStrings), 5); + AssertMethodId(map, nameof(IEditorServerNexus.OpenDocument), 6); + AssertMethodId(map, nameof(IEditorServerNexus.LeaveDocument), 7); + AssertMethodId(map, nameof(IEditorServerNexus.SaveDraft), 8); + AssertMethodId(map, nameof(IEditorServerNexus.Whisper), 9); + AssertMethodId(map, nameof(IEditorServerNexus.BroadcastSystemAnnouncement), 10); + AssertMethodId(map, nameof(IEditorServerNexus.ListActiveEditors), 11); + AssertMethodId(map, nameof(IEditorServerNexus.UploadAttachment), 12); + AssertMethodId(map, nameof(IEditorServerNexus.StreamEdits), 13); + } + + [Test] + public void GeneratorParity_EditorClientInterface_AssignsExpectedIds() + { + var map = MethodIdMap.Build(typeof(IEditorClientNexus)); + AssertMethodId(map, nameof(IEditorClientNexus.DraftSaved), 0); + AssertMethodId(map, nameof(IEditorClientNexus.EditorJoined), 1); + AssertMethodId(map, nameof(IEditorClientNexus.EditorLeft), 2); + AssertMethodId(map, nameof(IEditorClientNexus.WhisperReceived), 3); + AssertMethodId(map, nameof(IEditorClientNexus.SystemAnnouncement), 4); + } + + [Test] + public void Build_IsDeterministic_AcrossInvocations() + { + var first = MethodIdMap.Build(typeof(IEditorServerNexus)); + var second = MethodIdMap.Build(typeof(IEditorServerNexus)); + var third = MethodIdMap.Build(typeof(IEditorServerNexus)); + + foreach (var (method, id) in first) + { + Assert.That(second[method], Is.EqualTo(id), + $"Build order changed for {method.Name} on second call"); + Assert.That(third[method], Is.EqualTo(id), + $"Build order changed for {method.Name} on third call"); + } + } + + [Test] + public void Build_ExplicitMethodId_TakesPrecedenceAndSkipsSlot() + { + // Synthetic interface with one explicit id to validate the precedence + slot-skipping + // logic without depending on the demo interfaces. + var map = MethodIdMap.Build(typeof(IExplicitIdSample)); + + AssertMethodId(map, nameof(IExplicitIdSample.FirstMethod), 0); + AssertMethodId(map, nameof(IExplicitIdSample.PinnedToFive), 5); + // SecondMethod is third in source order; gets next available id (skipping 5). + AssertMethodId(map, nameof(IExplicitIdSample.SecondMethod), 1); + } + + [Test] + public void Build_ExcludesIgnoredMethods_AndDoesNotShiftFollowingIds() + { + // The generator filters [NexusMethod(Ignore = true)] methods *before* assigning ids, so + // an ignored method receives no id and does not reserve a slot. The runtime map must match: + // First=0, Third=1, and Ignored absent entirely. (Pre-fix, the map would have included + // Ignored at id 1 and pushed Third to 2 — a silent off-by-one against the generator.) + var map = MethodIdMap.Build(typeof(IIgnoreSample)); + + AssertMethodId(map, nameof(IIgnoreSample.First), 0); + AssertMethodId(map, nameof(IIgnoreSample.Third), 1); + Assert.That(map.Keys.Any(m => m.Name == nameof(IIgnoreSample.Ignored)), Is.False, + "Ignored methods must be excluded from the map entirely (they get no generator id)."); + } + + [Test] + public void Build_OrdersGenericInheritedInterfaces_ByDisplayName_NotFullName() + { + // The generator orders inherited interfaces by INamedTypeSymbol.ToDisplayString() (ordinal), + // e.g. "...IGen" / "...IGen". Type.FullName uses the CLR generic encoding + // ("...IGen`1[[System.Boolean, ...]]") whose type-arg ordering diverges here: by display + // name IGen sorts before IGen ('S' < 'b'), but by FullName IGen sorts + // first ('Boolean' < 'Guid'). The map must follow the generator (display-name) order. + var map = MethodIdMap.Build(typeof(IGenericDerived)); + + AssertMethodId(map, nameof(IGenericDerived.Direct), 0); + // Direct method first (id 0); then inherited-interface methods in display-name order. + AssertGenericMethodId(map, typeof(Guid), 1); // IGen.GenMethod + AssertGenericMethodId(map, typeof(bool), 2); // IGen.GenMethod + } + + private static void AssertMethodId(System.Collections.Generic.Dictionary map, string methodName, ushort expectedId) + { + var method = map.Keys.FirstOrDefault(m => m.Name == methodName); + Assert.That(method, Is.Not.Null, $"Method {methodName} missing from map"); + Assert.That(map[method!], Is.EqualTo(expectedId), $"Method {methodName} mapped to wrong id"); + } + + // Disambiguates the two IGen.GenMethod overloads by their single parameter type. + private static void AssertGenericMethodId(System.Collections.Generic.Dictionary map, System.Type paramType, ushort expectedId) + { + var method = map.Keys.FirstOrDefault(m => + m.Name == nameof(IGen.GenMethod) && + m.GetParameters() is { Length: 1 } p && p[0].ParameterType == paramType); + Assert.That(method, Is.Not.Null, $"GenMethod({paramType.Name}) missing from map"); + Assert.That(map[method!], Is.EqualTo(expectedId), $"GenMethod({paramType.Name}) mapped to wrong id"); + } + + private interface IExplicitIdSample + { + void FirstMethod(); + [NexNet.NexusMethod(MethodId = 5)] + void PinnedToFive(); + void SecondMethod(); + } + + private interface IIgnoreSample + { + void First(); + [NexNet.NexusMethod(Ignore = true)] + void Ignored(); + void Third(); + } + + private interface IGen + { + void GenMethod(T value); + } + + private interface IGenericDerived : IGen, IGen + { + void Direct(); + } +} diff --git a/src/NexNet.Testing.Tests/NexNet.Testing.Tests.csproj b/src/NexNet.Testing.Tests/NexNet.Testing.Tests.csproj new file mode 100644 index 00000000..d83c960f --- /dev/null +++ b/src/NexNet.Testing.Tests/NexNet.Testing.Tests.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + false + latest + true + $(BaseIntermediateOutputPath)gen + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/NexNet.Testing.Tests/NexusTestHostTests.cs b/src/NexNet.Testing.Tests/NexusTestHostTests.cs new file mode 100644 index 00000000..7ce6053e --- /dev/null +++ b/src/NexNet.Testing.Tests/NexusTestHostTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Threading.Tasks; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +internal class NexusTestHostTests +{ + [SetUp] + public void ResetEditorState() => EditorServerNexus.ResetAll(); + + [Test] + public async Task EndToEnd_InvokeServerMethodOverInProcess() + { + var sNexus = new EditorServerNexus(); + var cNexus = new EditorClientNexus(); + + await using var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>( + serverNexusFactory: () => sNexus, + clientNexusFactory: () => cNexus); + + var client = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + var result = await client.Server.Ping(42); + Assert.That(result, Is.EqualTo(42)); + Assert.That(sNexus.PingCount, Is.EqualTo(1)); + + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task ManyInvocations_OneClient() + { + var sNexus = new EditorServerNexus(); + var cNexus = new EditorClientNexus(); + + await using var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>( + serverNexusFactory: () => sNexus, + clientNexusFactory: () => cNexus); + + var client = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + for (int i = 0; i < 20; i++) + { + var r = await client.Server.Ping(i); + Assert.That(r, Is.EqualTo(i)); + } + + Assert.That(sNexus.PingCount, Is.EqualTo(20)); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task SharedClientNexusInstance_ThrowsOnSecondConnect() + { + var sharedClient = new EditorClientNexus(); + + await using var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>( + serverNexusFactory: () => new EditorServerNexus(), + clientNexusFactory: () => sharedClient); + + await host.ConnectAsAsync(TestIdentity.Of("alice")) + .WaitAsync(TimeSpan.FromSeconds(5)); + + var ex = Assert.ThrowsAsync( + async () => await host.ConnectAsAsync(TestIdentity.Of("bob")) + .WaitAsync(TimeSpan.FromSeconds(5))); + Assert.That(ex!.Message, Does.Contain("clientNexusFactory")); + } + + [Test] + public async Task MultipleClients_CanConnectAgainstSameHost() + { + await using var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>( + serverNexusFactory: () => new EditorServerNexus(), + clientNexusFactory: () => new EditorClientNexus()); + + var c1 = await host.ConnectAsAsync(TestIdentity.Of("alice")) + .WaitAsync(TimeSpan.FromSeconds(5)); + var c2 = await host.ConnectAsAsync(TestIdentity.Of("bob")) + .WaitAsync(TimeSpan.FromSeconds(5)); + var c3 = await host.ConnectAsAsync(TestIdentity.Of("carol")) + .WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.That(await c1.Server.Ping(1), Is.EqualTo(1)); + Assert.That(await c2.Server.Ping(2), Is.EqualTo(2)); + Assert.That(await c3.Server.Ping(3), Is.EqualTo(3)); + } + + /// + /// Drives the harness with a burst of invocations and a fire-and-forget Notify, then checks + /// QuiesceAsync only completes after every invocation has returned and every byte has been + /// drained. Validates all four counters (bytesInTransit, inDispatch, pendingResults, + /// activePipes) wired into real session-level activity, not unit-test-only mocks. + /// + [Test] + public async Task QuiesceAsync_AwaitsRealActivityEndToEnd() + { + var sNexus = new EditorServerNexus(); + var cNexus = new EditorClientNexus(); + + await using var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>( + serverNexusFactory: () => sNexus, + clientNexusFactory: () => cNexus); + + var client = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + // Mix waited-on calls (exercise inDispatch + pendingResults + bytesInTransit) + // with fire-and-forget Notify (exercise bytesInTransit + inDispatch without + // a return-value path). + var awaited = new System.Collections.Generic.List(); + for (int i = 0; i < 50; i++) + { + awaited.Add(client.Server.Ping(i).AsTask()); + await client.Server.Notify($"msg-{i}"); + } + await Task.WhenAll(awaited); + + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(sNexus.PingCount, Is.EqualTo(50)); + } +} diff --git a/src/NexNet.Testing.Tests/PipeRecordingTests.cs b/src/NexNet.Testing.Tests/PipeRecordingTests.cs new file mode 100644 index 00000000..7a0100ac --- /dev/null +++ b/src/NexNet.Testing.Tests/PipeRecordingTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Testing.Streaming; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +internal class PipeRecordingTests +{ + [Test] + public async Task TappingPipeReader_RecordsConsumedSliceOnAdvanceTo() + { + var pipe = new Pipe(); + var recording = new PipeRecording(); + var reader = new TappingPipeReader(pipe.Reader, recording); + + await pipe.Writer.WriteAsync(new byte[] { 1, 2, 3, 4, 5 }); + await pipe.Writer.CompleteAsync(); + + var read = await reader.ReadAsync(); + Assert.That(read.Buffer.Length, Is.EqualTo(5)); + + // Advance past 3 bytes; the remaining 2 stay in the buffer. + reader.AdvanceTo(read.Buffer.GetPosition(3)); + + Assert.That(recording.ConsumedBytes.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 })); + } + + [Test] + public async Task TappingPipeWriter_RecordsAdvancedBytes() + { + var pipe = new Pipe(); + var recording = new PipeRecording(); + var writer = new TappingPipeWriter(pipe.Writer, recording); + + var mem = writer.GetMemory(4); + new byte[] { 10, 20, 30, 40 }.CopyTo(mem); + writer.Advance(4); + await writer.FlushAsync(); + + Assert.That(recording.WrittenBytes.ToArray(), Is.EqualTo(new byte[] { 10, 20, 30, 40 })); + } + + [Test] + public async Task WaitForBytesAsync_CompletesAfterRecording() + { + var recording = new PipeRecording(); + var waiter = recording.WaitForBytesAsync(3); + Assert.That(waiter.IsCompleted, Is.False); + + recording.RecordConsumed(new byte[] { 1, 2, 3, 4 }); + + await waiter.WaitAsync(TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task Completion_NotifiesWaiters() + { + var recording = new PipeRecording(); + var completion = recording.WaitForCompletionAsync(); + Assert.That(completion.IsCompleted, Is.False); + + recording.RecordCompletion(null); + + await completion.WaitAsync(TimeSpan.FromSeconds(1)); + Assert.That(recording.IsCompleted, Is.True); + Assert.That(recording.FaultedWith, Is.Null); + } + + [Test] + public void Completion_CarriesException() + { + var recording = new PipeRecording(); + var error = new InvalidOperationException("boom"); + recording.RecordCompletion(error); + Assert.That(recording.IsCompleted, Is.True); + Assert.That(recording.FaultedWith, Is.SameAs(error)); + } +} diff --git a/src/NexNet.Testing.Tests/QuiescenceTrackerTests.cs b/src/NexNet.Testing.Tests/QuiescenceTrackerTests.cs new file mode 100644 index 00000000..91a4d128 --- /dev/null +++ b/src/NexNet.Testing.Tests/QuiescenceTrackerTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Testing.Quiescence; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +internal class QuiescenceTrackerTests +{ + [Test] + public async Task EmptyTracker_QuiescesImmediately() + { + var tracker = new QuiescenceTracker(); + await tracker.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task DispatchEnterExit_QuiescesAfterExit() + { + var tracker = new QuiescenceTracker(); + var counters = tracker.GetCountersFor(sessionId: 1); + + counters.EnterDispatch(); + tracker.SignalChange(); + + var quiesceTask = tracker.QuiesceAsync(); + // Should not complete while a dispatch is active. + await Task.Delay(50); + Assert.That(quiesceTask.IsCompleted, Is.False); + + counters.ExitDispatch(); + tracker.SignalChange(); + + await quiesceTask.WaitAsync(TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task ActivePipes_BlockQuiescence() + { + var tracker = new QuiescenceTracker(); + var counters = tracker.GetCountersFor(sessionId: 1); + + counters.OpenPipe(); + tracker.SignalChange(); + + var quiesceTask = tracker.QuiesceAsync(); + await Task.Delay(50); + Assert.That(quiesceTask.IsCompleted, Is.False); + + counters.ClosePipe(); + tracker.SignalChange(); + + await quiesceTask.WaitAsync(TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task PendingInvocationProbe_BlocksUntilZero() + { + var tracker = new QuiescenceTracker(); + int pending = 2; + var key = new object(); + tracker.RegisterPendingInvocationProbe(key, () => pending); + + var quiesceTask = tracker.QuiesceAsync(); + await Task.Delay(50); + Assert.That(quiesceTask.IsCompleted, Is.False); + + pending = 0; + tracker.SignalChange(); + await quiesceTask.WaitAsync(TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task PendingInvocationProbe_Unregister_RemovesProbe() + { + var tracker = new QuiescenceTracker(); + int pending = 5; + var key = new object(); + tracker.RegisterPendingInvocationProbe(key, () => pending); + + var quiesceTask = tracker.QuiesceAsync(); + await Task.Delay(50); + Assert.That(quiesceTask.IsCompleted, Is.False); + + tracker.UnregisterPendingInvocationProbe(key); + tracker.SignalChange(); + await quiesceTask.WaitAsync(TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task QuiesceAsync_HonorsCancellation() + { + var tracker = new QuiescenceTracker(); + var counters = tracker.GetCountersFor(sessionId: 1); + counters.EnterDispatch(); + + using var cts = new CancellationTokenSource(); + var quiesceTask = tracker.QuiesceAsync(cts.Token); + await Task.Delay(50); + Assert.That(quiesceTask.IsCompleted, Is.False); + + cts.Cancel(); + Assert.CatchAsync( + async () => await quiesceTask.WaitAsync(TimeSpan.FromSeconds(1))); + } + + [Test] + public async Task BytesInTransit_BlocksQuiescence() + { + var tracker = new QuiescenceTracker(); + var counters = tracker.GetCountersFor(sessionId: 1); + counters.AddBytesInTransit(128); + tracker.SignalChange(); + + var quiesceTask = tracker.QuiesceAsync(); + await Task.Delay(50); + Assert.That(quiesceTask.IsCompleted, Is.False); + + counters.AddBytesInTransit(-128); + tracker.SignalChange(); + await quiesceTask.WaitAsync(TimeSpan.FromSeconds(1)); + } +} diff --git a/src/NexNet.Testing.Tests/StreamingExtensionsTests.cs b/src/NexNet.Testing.Tests/StreamingExtensionsTests.cs new file mode 100644 index 00000000..add263eb --- /dev/null +++ b/src/NexNet.Testing.Tests/StreamingExtensionsTests.cs @@ -0,0 +1,90 @@ +using System; +using System.Threading.Tasks; +using NexNet.Testing.Streaming; +using NUnit.Framework; +#pragma warning disable VSTHRD200 + +namespace NexNet.Testing.Tests; + +internal class StreamingExtensionsTests +{ + private static Task> CreateHost() + => NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>(); + + [SetUp] + public void ClearServerNexusStatics() + { + EditorServerNexus.LastUploadedBytes = null; + EditorServerNexus.LastCollectedItems = null; + } + + [Test] + public async Task PipeUpload_DeliversFullPayload() + { + await using var host = await CreateHost(); + var client = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + var payload = new byte[64]; + for (int i = 0; i < payload.Length; i++) payload[i] = (byte)(i * 3); + + await using var pipe = client.CreatePipe(); + var serverCall = client.Server.Upload(pipe).AsTask(); + await pipe.PipeUploadAsync(payload); + await serverCall.WaitAsync(TimeSpan.FromSeconds(2)); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(EditorServerNexus.LastUploadedBytes, Is.EqualTo(payload)); + } + + [Test] + public async Task PipeDownload_ReceivesFullPayload() + { + await using var host = await CreateHost(); + var client = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + await using var pipe = client.CreatePipe(); + var serverCall = client.Server.Download(pipe, byteCount: 128).AsTask(); + var bytes = await pipe.PipeDownloadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + await serverCall.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(bytes.Length, Is.EqualTo(128)); + for (int i = 0; i < bytes.Length; i++) + Assert.That(bytes[i], Is.EqualTo((byte)i)); + } + + [Test] + public async Task ChannelPublish_DeliversTypedItems() + { + await using var host = await CreateHost(); + var client = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + var items = new[] { "alpha", "beta", "gamma", "delta" }; + + await using var pipe = client.CreatePipe(); + var serverCall = client.Server.CollectStrings(pipe).AsTask(); + await pipe.ChannelPublishAsync(items); + await serverCall.WaitAsync(TimeSpan.FromSeconds(2)); + await host.QuiesceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(EditorServerNexus.LastCollectedItems, Is.EqualTo(items)); + } + + [Test] + public async Task ChannelCollect_ReceivesTypedItems() + { + await using var host = await CreateHost(); + var client = await host.ConnectAsAsync(TestIdentity.Of("alice")); + + var items = new[] { "uno", "dos", "tres" }; + + await using var pipe = client.CreatePipe(); + var serverCall = client.Server.PublishStrings(pipe, items).AsTask(); + var collected = await pipe.ChannelCollectAsync().AsTask() + .WaitAsync(TimeSpan.FromSeconds(2)); + await serverCall.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(collected, Is.EqualTo(items)); + } +} diff --git a/src/NexNet.Testing/Assertions.cs b/src/NexNet.Testing/Assertions.cs new file mode 100644 index 00000000..c22ca9d9 --- /dev/null +++ b/src/NexNet.Testing/Assertions.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq.Expressions; +using System.Threading.Tasks; +using NexNet.Collections; +using NexNet.Invocation; +using NexNet.Testing.Recording; + +namespace NexNet.Testing; + +/// +/// Server-side assertion API exposed on the host. Resolves the user expression to a +/// , looks up the corresponding method id via the +/// , scans the host's recorder, and either succeeds, throws a +/// , or awaits a match. +/// +public sealed partial class NexusTestHost + where TServerNexus : ServerNexusBase, IInvocationMethodHash, ICollectionConfigurer, new() + where TClientProxy : ProxyInvocationBase, IInvocationMethodHash, new() + where TClientNexus : ClientNexusBase, IMethodInvoker, IInvocationMethodHash, ICollectionConfigurer, new() + where TServerProxy : ProxyInvocationBase, IProxyInvoker, IInvocationMethodHash, new() +{ + private RecorderAssertions? _serverAssertions; + private RecorderAssertions ServerAssertions => + _serverAssertions ??= new RecorderAssertions(ServerRecorder); + + /// + /// Asserts the server has dispatched a method matching + /// exactly times. Call after QuiesceAsync so that any + /// in-flight invocations have been recorded. + /// + public void AssertReceived(Expression> expression, int times = 1) + => ServerAssertions.AssertReceived(expression, times); + + /// + /// Asserts no recorded invocation matches . Call after + /// QuiesceAsync. + /// + public void AssertNotReceived(Expression> expression) + => ServerAssertions.AssertNotReceived(expression); + + /// + /// Awaits an invocation matching arriving within + /// (default 5 seconds). Returns immediately if a match is + /// already in the recorder; throws when the deadline + /// elapses with no match. + /// + public Task WaitFor( + Expression> expression, + TimeSpan? timeout = null) + => ServerAssertions.WaitFor(expression, timeout); +} diff --git a/src/NexNet.Testing/Authentication/TestAuthenticationStore.cs b/src/NexNet.Testing/Authentication/TestAuthenticationStore.cs new file mode 100644 index 00000000..d14a50b3 --- /dev/null +++ b/src/NexNet.Testing/Authentication/TestAuthenticationStore.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; + +namespace NexNet.Testing.Authentication; + +/// +/// Registry that the host installs as ServerConfig.OnAuthenticateOverride. Maps opaque +/// token bytes (issued by ) back to the original +/// . When a client connects with a registered token, the server-side +/// override produces the corresponding identity; unknown tokens resolve to null and the +/// server drops the connection through the existing auth path. +/// +/// +/// Tokens are not garbage-collected: a long-lived host that issues many tokens accumulates them +/// for the host's lifetime. This is by design — the store doesn't know which tokens are still +/// in use, and test scenarios that need long-lived hosts can call between +/// scenarios. Typical per-test-method host construction releases the store when the host is +/// disposed. +/// +internal sealed class TestAuthenticationStore +{ + private readonly ConcurrentDictionary _byTokenKey = new(StringComparer.Ordinal); + private readonly Func?, ValueTask> _override; + + public TestAuthenticationStore() + { + // Cache the delegate as a field rather than allocating a fresh closure on every read of + // OverrideDelegate. The harness only reads it once during host construction today, but + // the previous getter-builds-lambda pattern is the kind of thing that would silently + // burn allocations if someone copied it into a hot path. + _override = token => + { + if (token is null || token.Value.IsEmpty) + return new ValueTask((IIdentity?)null); + var key = System.Text.Encoding.UTF8.GetString(token.Value.Span); + return _byTokenKey.TryGetValue(key, out var id) + ? new ValueTask(id) + : new ValueTask((IIdentity?)null); + }; + } + + /// + /// Records an identity under a fresh opaque token and returns the bytes the client will + /// send as its authentication payload. + /// + public Memory IssueToken(IIdentity identity) + { + var key = Guid.NewGuid().ToString("N"); + _byTokenKey[key] = identity; + return System.Text.Encoding.UTF8.GetBytes(key); + } + + /// The delegate to install on ServerConfig.OnAuthenticateOverride. + public Func?, ValueTask> OverrideDelegate => _override; + + /// + /// Removes every issued token. Long-lived hosts that issue many tokens across scenarios can + /// call this between scenarios to bound memory; tokens issued before Clear stop + /// resolving and subsequent connect attempts with those tokens are rejected as if they were + /// never issued. + /// + public void Clear() => _byTokenKey.Clear(); +} diff --git a/src/NexNet.Testing/Authentication/TestIdentity.cs b/src/NexNet.Testing/Authentication/TestIdentity.cs new file mode 100644 index 00000000..6b417367 --- /dev/null +++ b/src/NexNet.Testing/Authentication/TestIdentity.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace NexNet.Testing; + +/// +/// Lightweight backed by an in-memory role list. The host's +/// authentication-override delegate uses these to populate connections issued via +/// NexusTestHost.ConnectAsAsync. +/// +public sealed class TestIdentity : IIdentity +{ + /// + public string? DisplayName { get; } + + /// Roles attached to the identity. + public IReadOnlyList Roles { get; } + + private TestIdentity(string name, IReadOnlyList roles) + { + DisplayName = name; + Roles = roles; + } + + /// + /// Constructs a fake identity with the given name and (optional) roles. Used by tests to + /// pass arbitrary identities through the harness's authentication override without + /// touching the user's real auth code. + /// + public static TestIdentity Of(string name, params string[] roles) + => new TestIdentity(name, roles ?? Array.Empty()); + + /// True when this identity carries . + public bool IsInRole(string role) + { + foreach (var r in Roles) + if (string.Equals(r, role, StringComparison.Ordinal)) + return true; + return false; + } +} diff --git a/src/NexNet.Testing/GroupIntrospector.cs b/src/NexNet.Testing/GroupIntrospector.cs new file mode 100644 index 00000000..11f49b84 --- /dev/null +++ b/src/NexNet.Testing/GroupIntrospector.cs @@ -0,0 +1,22 @@ +using NexNet.Invocation; + +namespace NexNet.Testing; + +/// +/// Indexer-style accessor returned by +/// ; +/// lookups by name produce a backed by the live server-side group +/// registry, so subsequent reads reflect the current membership. +/// +public sealed class GroupIntrospector +{ + private readonly IGroupRegistry _registry; + + internal GroupIntrospector(IGroupRegistry registry) + { + _registry = registry; + } + + /// Returns a view onto the named group; group names need not exist yet. + public GroupView this[string groupName] => new(groupName, _registry); +} diff --git a/src/NexNet.Testing/GroupView.cs b/src/NexNet.Testing/GroupView.cs new file mode 100644 index 00000000..740edc32 --- /dev/null +++ b/src/NexNet.Testing/GroupView.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; +using NexNet.Invocation; + +namespace NexNet.Testing; + +/// +/// Read-only view onto a single named group on the host. Returned from +/// +/// indexer; snapshots membership at the time the property is read so tests can compare against +/// expected sets without races on iteration. +/// +public sealed class GroupView +{ + private readonly string _groupName; + private readonly IGroupRegistry _registry; + + internal GroupView(string groupName, IGroupRegistry registry) + { + _groupName = groupName; + _registry = registry; + } + + /// The group name as registered on the server. + public string Name => _groupName; + + /// + /// Snapshot of the session ids currently in the group. Returns an empty array if the group + /// has no members or has never been used. + /// + public long[] Members => _registry.GetLocalGroupMembers(_groupName).Select(s => s.Id).ToArray(); + + /// The number of sessions currently in the group. + public int Count => Members.Length; +} diff --git a/src/NexNet.Testing/NexNet.Testing.csproj b/src/NexNet.Testing/NexNet.Testing.csproj new file mode 100644 index 00000000..458c35b9 --- /dev/null +++ b/src/NexNet.Testing/NexNet.Testing.csproj @@ -0,0 +1,23 @@ + + + + In-process transport and test harness primitives for NexNet. Test your nexus implementations without standing up sockets, ports, TLS, or fake auth providers. + 0.15.0 + true + + $(MSBuildProjectDirectory)/README.md + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/NexNet.Testing/NexusAssertionException.cs b/src/NexNet.Testing/NexusAssertionException.cs new file mode 100644 index 00000000..ddca2519 --- /dev/null +++ b/src/NexNet.Testing/NexusAssertionException.cs @@ -0,0 +1,30 @@ +using System; + +namespace NexNet.Testing; + +/// +/// Thrown by AssertReceived / AssertNotReceived when an expected invocation is +/// missing, an unexpected one is observed, or the call-count assertion fails. NUnit, xUnit, +/// and MSTest each surface uncaught exceptions as test failures, so the harness needs no +/// framework-specific integration. +/// +public sealed class NexusAssertionException : Exception +{ + /// + /// Creates a new with the supplied diagnostic message. + /// + public NexusAssertionException(string message) + : base(message) + { + } + + /// + /// Creates a new wrapping an inner exception, e.g. + /// when a user-supplied predicate inside Arg.Is<T>(p) throws and the harness + /// wants to surface the original cause rather than masking it as a no-match. + /// + public NexusAssertionException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/NexNet.Testing/NexusTestClient.cs b/src/NexNet.Testing/NexusTestClient.cs new file mode 100644 index 00000000..caaa9493 --- /dev/null +++ b/src/NexNet.Testing/NexusTestClient.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq.Expressions; +using System.Threading.Tasks; +using NexNet.Collections; +using NexNet.Invocation; +using NexNet.Pipes; +using NexNet.Testing.Recording; + +namespace NexNet.Testing; + +/// +/// Per-connection handle handed back by NexusTestHost.ConnectAsync. Exposes the +/// client-side proxy (for outbound invocations), the user's client nexus instance (for +/// inspection of incoming-callback state), and per-client assertion methods that scan only +/// the invocations this client's session dispatched. +/// +public sealed class NexusTestClient + where TClientNexus : ClientNexusBase, IMethodInvoker, IInvocationMethodHash, ICollectionConfigurer + where TServerProxy : ProxyInvocationBase, IProxyInvoker, IInvocationMethodHash, new() +{ + private readonly InvocationRecorder _recorder; + private readonly RecorderAssertions _assertions; + + /// Underlying . + public NexusClient Client { get; } + + /// Server-side proxy for outbound invocations. + public TServerProxy Server => Client.Proxy; + + /// The user's client nexus instance. + public TClientNexus Nexus { get; } + + internal NexusTestClient( + NexusClient client, + TClientNexus nexus, + InvocationRecorder recorder) + { + Client = client; + Nexus = nexus; + _recorder = recorder; + _assertions = new RecorderAssertions(recorder); + } + + /// + /// Convenience: rents a duplex pipe via the client's session context. The same pipe can then + /// be passed to a server proxy call that accepts an + /// parameter, and driven via the streaming extensions in NexNet.Testing.Streaming. + /// + public IRentedNexusDuplexPipe CreatePipe() => Nexus.Context.CreatePipe(); + + /// + /// Asserts this client's nexus has dispatched a method matching + /// exactly times. Call after host.QuiesceAsync so that any + /// in-flight invocations have been recorded. + /// + public void AssertReceived(Expression> expression, int times = 1) + => _assertions.AssertReceived(expression, times); + + /// + /// Asserts no invocation matching has been dispatched on this + /// client's nexus. Call after host.QuiesceAsync. + /// + public void AssertNotReceived(Expression> expression) + => _assertions.AssertNotReceived(expression); + + /// + /// Awaits an invocation matching arriving on this client's + /// nexus within (default 5 seconds). Returns immediately if a + /// match is already recorded; throws on deadline. + /// + public Task WaitFor( + Expression> expression, + TimeSpan? timeout = null) + => _assertions.WaitFor(expression, timeout); +} diff --git a/src/NexNet.Testing/NexusTestHost.cs b/src/NexNet.Testing/NexusTestHost.cs new file mode 100644 index 00000000..ad590a35 --- /dev/null +++ b/src/NexNet.Testing/NexusTestHost.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using NexNet.Collections; +using NexNet.Invocation; +using NexNet.Logging; +using NexNet.Testing.Authentication; +using NexNet.Testing.Quiescence; +using NexNet.Testing.Recording; +using NexNet.Testing.Streaming; +using NexNet.Testing.Transports.InProcess; + +namespace NexNet.Testing; + +/// +/// Static entry point for the test harness. Use to construct a +/// configured host bound to a fresh in-process endpoint with the recorder, pipe tap, and +/// auth override pre-installed. +/// +public static class NexusTestHost +{ + /// + /// Creates and starts a . + /// Each call to and + /// MUST return a fresh instance: NexNet stores per-session state on the nexus, so sharing + /// instances across connections corrupts state and hangs the second connect. The harness + /// detects duplicate instances and throws a clear error rather than silently hanging. Omit + /// the factory arguments to use the default new TServerNexus() / new TClientNexus(). + /// + /// The user's server nexus class, generated via + /// [Nexus(NexusType = NexusType.Server)]. + /// The nested ClientProxy type the generator emits + /// inside . + /// The user's client nexus class, generated via + /// [Nexus(NexusType = NexusType.Client)]. + /// The nested ServerProxy type the generator emits + /// inside . + /// + /// The four type parameters are required because the C# compiler cannot infer the nested + /// proxy types from the outer nexus types alone. A typical call looks like: + /// + /// await using var host = await NexusTestHost.CreateAsync< + /// MyServerNexus, MyServerNexus.ClientProxy, + /// MyClientNexus, MyClientNexus.ServerProxy>(); + /// + /// + public static async Task> CreateAsync( + Func? serverNexusFactory = null, + Func? clientNexusFactory = null) + where TServerNexus : ServerNexusBase, IInvocationMethodHash, ICollectionConfigurer, new() + where TClientProxy : ProxyInvocationBase, IInvocationMethodHash, new() + where TClientNexus : ClientNexusBase, IMethodInvoker, IInvocationMethodHash, ICollectionConfigurer, new() + where TServerProxy : ProxyInvocationBase, IProxyInvoker, IInvocationMethodHash, new() + { + var host = new NexusTestHost( + serverNexusFactory ?? (static () => new TServerNexus()), + clientNexusFactory ?? (static () => new TClientNexus())); + await host.StartAsync().ConfigureAwait(false); + return host; + } +} + +/// +/// Configured test host: owns the in-process server, recorder/tracker state, and connect +/// helpers. One host per logical scenario; dispose to stop the server and release the +/// rendezvous endpoint. +/// +public sealed partial class NexusTestHost + : IAsyncDisposable + where TServerNexus : ServerNexusBase, IInvocationMethodHash, ICollectionConfigurer, new() + where TClientProxy : ProxyInvocationBase, IInvocationMethodHash, new() + where TClientNexus : ClientNexusBase, IMethodInvoker, IInvocationMethodHash, ICollectionConfigurer, new() + where TServerProxy : ProxyInvocationBase, IProxyInvoker, IInvocationMethodHash, new() +{ + private readonly Func _serverNexusFactory; + private readonly Func _clientNexusFactory; + private readonly HashSet _seenServerNexuses = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _seenClientNexuses = new(ReferenceEqualityComparer.Instance); + private readonly string _endpoint; + private readonly InProcessServerConfig _serverConfig; + private readonly NexusServer _server; + private readonly QuiescenceTracker _tracker = new(); + private readonly TestAuthenticationStore _authStore = new(); + private readonly TestInvocationInterceptor _interceptor; + private readonly TestPipeFactory _pipeFactory; + internal QuiescenceTracker Tracker => _tracker; + internal TestAuthenticationStore AuthStore => _authStore; + + internal NexusTestHost(Func serverNexusFactory, Func clientNexusFactory) + { + _serverNexusFactory = serverNexusFactory; + _clientNexusFactory = clientNexusFactory; + _endpoint = $"nexus-test-{Guid.NewGuid():N}"; + + var counters = _tracker.GetCountersFor(0); + var recorder = new InvocationRecorder(); + _interceptor = new TestInvocationInterceptor(recorder, counters, _tracker); + _pipeFactory = new TestPipeFactory(counters, _tracker); + + _serverConfig = new InProcessServerConfig + { + Endpoint = _endpoint, + Authenticate = true, + InvocationInterceptor = _interceptor, + PipeFactory = _pipeFactory, + OnAuthenticateOverride = _authStore.OverrideDelegate, + Counters = counters, + Tracker = _tracker, + InternalOnSessionSetup = RegisterSessionWithTracker, + }; + + _server = new NexusServer(_serverConfig, WrappedServerFactory, null); + ServerRecorder = recorder; + } + + /// + /// Wraps the user-supplied server-nexus factory with duplicate-instance detection. Returning + /// the same instance on a subsequent connect would silently corrupt per-session state on the + /// shared SessionContext; surfacing it as an exception turns a multi-client hang into + /// a discoverable misuse error. + /// + private TServerNexus WrappedServerFactory() + { + var instance = _serverNexusFactory(); + lock (_seenServerNexuses) + { + if (!_seenServerNexuses.Add(instance)) + throw new InvalidOperationException( + "serverNexusFactory returned an instance that was already used by a previous session. " + + "Each session needs its own nexus instance because NexNet stores per-session state on it " + + "(SessionContext, identity, etc.). Use `() => new TServerNexus()` and capture cross-session " + + "state in static fields, closures, or external collaborators instead."); + } + return instance; + } + + /// + /// Registers a freshly-constructed session's PendingInvocationCount probe with the + /// tracker so quiescence accounts for invocations the registry knows about. The session + /// instance is used as the probe key, so the matching unregister can be a single lookup. + /// + private void RegisterSessionWithTracker(NexNet.Internals.INexusSession session) + { + var stateManager = session.SessionInvocationStateManager; + _tracker.RegisterPendingInvocationProbe(session, () => stateManager.PendingInvocationCount); + } + + /// Recorder capturing every invocation the server dispatched. Internal because + /// the public surface is the assertion API (, + /// , ) plus + /// ; user code that needs the raw recorder + /// should consume one of those layers. + internal InvocationRecorder ServerRecorder { get; } + + /// + /// Number of invocations the server has dispatched since the host was created. Useful for + /// scenario-level "did the server actually receive anything?" sanity checks where the + /// assertion API is too narrow. + /// + public int RecordedServerInvocationCount => ServerRecorder.Count; + + /// + /// Lazily-initialised view onto the server's group registry. host.Groups["editors"].Members + /// yields the session ids currently in "editors"; host.Groups["editors"].Count + /// returns the size. Reads snapshot the live registry, so callers can write tests like + /// Assert.That(host.Groups["editors"].Members, Has.Length.EqualTo(3)). + /// + public GroupIntrospector Groups + { + get + { + var sm = _server.SessionManagerInternal + ?? throw new System.InvalidOperationException( + "Server has not been started yet; call CreateAsync to obtain a started host."); + return new GroupIntrospector(sm.Groups); + } + } + + /// Returns when every counter the tracker watches has been zero across a yield. + public Task QuiesceAsync() => _tracker.QuiesceAsync(); + + internal async Task StartAsync() => await _server.StartAsync().ConfigureAwait(false); + + /// + /// Connects an anonymous client to the host. The default identity is empty; use + /// for fake-identity scenarios. + /// + public Task> ConnectAsync() + => ConnectAsAsync(identity: null); + + /// + /// Connects a client and stamps its handshake with the token mapped to . + /// The server's auth-override consults the harness's token store; if + /// is null an empty token is sent and the override returns null (so callers exercising + /// auth-must-fail paths can do so without disabling the override). + /// + public async Task> ConnectAsAsync(IIdentity? identity) + { + // Each client owns a fresh recorder + interceptor so per-client assertions only see + // dispatch on that client's session. The interceptor is also installed on the server's + // shared config (server-side recording aggregates across clients) — this duplicates the + // counter increments but those still net to zero per dispatch, so quiescence stays + // correct. + var clientRecorder = new InvocationRecorder(); + var clientCounters = _tracker.GetCountersFor(0); + var clientInterceptor = new TestInvocationInterceptor(clientRecorder, clientCounters, _tracker); + + var clientConfig = new InProcessClientConfig + { + Endpoint = _endpoint, + InternalOnSessionSetup = RegisterSessionWithTracker, + InvocationInterceptor = clientInterceptor, + PipeFactory = _pipeFactory, + }; + if (identity is not null) + { + var token = _authStore.IssueToken(identity); + clientConfig.Authenticate = () => token; + } + + var clientNexus = _clientNexusFactory(); + lock (_seenClientNexuses) + { + if (!_seenClientNexuses.Add(clientNexus)) + throw new InvalidOperationException( + "clientNexusFactory returned an instance that was already used by a previous client. " + + "Each client needs its own nexus instance because NexNet stores per-session state on it. " + + "Use `() => new TClientNexus()` and capture cross-client state externally instead."); + } + var client = new NexusClient(clientConfig, clientNexus); + await client.ConnectAsync().ConfigureAwait(false); + return new NexusTestClient(client, clientNexus, clientRecorder); + } + + /// + public async ValueTask DisposeAsync() + { + try + { + await _server.StopAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + // Swallowing teardown errors hides legitimate test diagnostics. Log via the + // configured logger so failures during dispose surface in test output rather than + // silently masking the real cause of a test failure. + _serverConfig.Logger?.LogError(ex, "NexusTestHost.DisposeAsync: server StopAsync threw"); + } + } +} diff --git a/src/NexNet.Testing/Quiescence/QuiescenceCounters.cs b/src/NexNet.Testing/Quiescence/QuiescenceCounters.cs new file mode 100644 index 00000000..5f2b1210 --- /dev/null +++ b/src/NexNet.Testing/Quiescence/QuiescenceCounters.cs @@ -0,0 +1,25 @@ +using System.Threading; + +namespace NexNet.Testing.Quiescence; + +/// +/// Per-session counter set tracked by the harness. Atomic increments/decrements; reads use a +/// volatile load to pick up the latest value. A owns one +/// instance per active session and aggregates them when answering QuiesceAsync. +/// +internal sealed class QuiescenceCounters +{ + private int _bytesInTransit; + private int _inDispatch; + private int _activePipes; + + public int BytesInTransit => Volatile.Read(ref _bytesInTransit); + public int InDispatch => Volatile.Read(ref _inDispatch); + public int ActivePipes => Volatile.Read(ref _activePipes); + + public void AddBytesInTransit(int delta) => Interlocked.Add(ref _bytesInTransit, delta); + public void EnterDispatch() => Interlocked.Increment(ref _inDispatch); + public void ExitDispatch() => Interlocked.Decrement(ref _inDispatch); + public void OpenPipe() => Interlocked.Increment(ref _activePipes); + public void ClosePipe() => Interlocked.Decrement(ref _activePipes); +} diff --git a/src/NexNet.Testing/Quiescence/QuiescenceTracker.cs b/src/NexNet.Testing/Quiescence/QuiescenceTracker.cs new file mode 100644 index 00000000..7625273f --- /dev/null +++ b/src/NexNet.Testing/Quiescence/QuiescenceTracker.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace NexNet.Testing.Quiescence; + +/// +/// Aggregates per-session quiescence state across every session associated with a single +/// NexusTestHost. returns when every contributing counter is +/// zero, with an await Task.Yield() re-check so synchronously-scheduled continuations +/// observe their changes before quiescence is declared. +/// +/// +/// The tracker exposes raw counter handles via so the interceptor, +/// pipe factory, and transport can record activity directly. Pending-invocation probes are +/// pull-style because the session's invocation-state registry is the source of truth; counting +/// writes through the interceptor would double-count. +/// +internal sealed class QuiescenceTracker +{ + private readonly object _gate = new(); + private readonly Dictionary _bySession = new(); + private readonly Dictionary> _pendingInvocationProbes = new(); + + private TaskCompletionSource _changeSignal = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public QuiescenceCounters GetCountersFor(long sessionId) + { + lock (_gate) + { + if (!_bySession.TryGetValue(sessionId, out var counters)) + { + counters = new QuiescenceCounters(); + _bySession[sessionId] = counters; + } + return counters; + } + } + + /// + /// Registers a probe under that returns the live pending-invocation + /// count for a single session. Probes are aggregated into the global total each time + /// samples the state. The is used to + /// unregister the probe later (typically when the session disconnects or the host is + /// disposed). + /// + public void RegisterPendingInvocationProbe(object key, Func probe) + { + lock (_gate) + { + _pendingInvocationProbes[key] = probe; + } + } + + /// + /// Removes a previously-registered probe. + /// + public void UnregisterPendingInvocationProbe(object key) + { + lock (_gate) + { + _pendingInvocationProbes.Remove(key); + } + } + + /// + /// Wakes any caller currently awaiting quiescence. Called by the counter mutators after + /// every change. + /// + public void SignalChange() + { + TaskCompletionSource toFire; + lock (_gate) + { + toFire = _changeSignal; + _changeSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + toFire.TrySetResult(); + } + + /// + /// Atomic sample: reads counters and snapshots the signal task under the same lock so a + /// caller awaiting the returned signal won't miss a that happens + /// after the sample but before the await. + /// + private (int bytesInTransit, int inDispatch, int activePipes, int pendingInvocations, Task signal) SampleAndSnapshotSignal() + { + int bytes = 0, dispatch = 0, pipes = 0, pending = 0; + Task signal; + lock (_gate) + { + foreach (var (_, counters) in _bySession) + { + bytes += counters.BytesInTransit; + dispatch += counters.InDispatch; + pipes += counters.ActivePipes; + } + foreach (var probe in _pendingInvocationProbes.Values) + { + try { pending += probe(); } + catch { /* probe may be stale post-disconnect */ } + } + signal = _changeSignal.Task; + } + return (bytes, dispatch, pipes, pending, signal); + } + + /// + /// Waits until all counters are zero. Re-checks after a to drain + /// synchronously-scheduled continuations that might enqueue further work without going + /// through any quiescence-aware channel. + /// + public async Task QuiesceAsync(CancellationToken cancellationToken = default) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var (bytes, dispatch, pipes, pending, signal) = SampleAndSnapshotSignal(); + if (bytes == 0 && dispatch == 0 && pipes == 0 && pending == 0) + { + // Drain synchronously-scheduled continuations. + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + (bytes, dispatch, pipes, pending, _) = SampleAndSnapshotSignal(); + if (bytes == 0 && dispatch == 0 && pipes == 0 && pending == 0) + return; + // Re-sample to take a fresh signal for the next wait. + (_, _, _, _, signal) = SampleAndSnapshotSignal(); + } + + if (cancellationToken.CanBeCanceled) + signal = signal.WaitAsync(cancellationToken); + + await signal.ConfigureAwait(false); + } + } +} diff --git a/src/NexNet.Testing/README.md b/src/NexNet.Testing/README.md new file mode 100644 index 00000000..1cf5a2a5 --- /dev/null +++ b/src/NexNet.Testing/README.md @@ -0,0 +1,78 @@ +# NexNet.Testing + +Test harness and in-process transport for [NexNet](https://github.com/Dtronix/NexNet). Exercise your +nexus implementations — server methods, client callbacks, authorization rules, broadcasts, group +routing, pipes, and channels — **without** standing up sockets, ports, TLS, or fake auth providers. + +`NexNet.Testing` is test-framework agnostic: assertions throw `NexusAssertionException`, which +NUnit, xUnit, and MSTest all surface as a normal test failure. No framework integration package is +required. + +## Install + +``` +dotnet add package NexNet.Testing +``` + +Add the reference from your test project (alongside the project that defines your nexuses). + +## Quickstart + +```csharp +await using var host = await NexusTestHost.CreateAsync< + EditorServerNexus, EditorServerNexus.ClientProxy, + EditorClientNexus, EditorClientNexus.ServerProxy>(); + +// Connect clients with fake identities (name + roles). Roles drive [NexusAuthorize] checks +// through your nexus's OnAuthorize override — you test your real authorization logic. +var alice = await host.ConnectAsAsync(TestIdentity.Of("alice", "Write")); +var bob = await host.ConnectAsAsync(TestIdentity.Of("bob", "Write")); +var carol = await host.ConnectAsAsync(TestIdentity.Of("carol", "Read")); + +await alice.Server.OpenDocument("design.md"); +await bob.Server.OpenDocument("design.md"); +await carol.Server.OpenDocument("recipe.txt"); + +// SaveDraft is [NexusAuthorize(Write)]-gated and broadcasts DraftSaved to +// Context.Clients.Group($"doc-{docId}") internally — a real business method, not a passthrough. +await alice.Server.SaveDraft("design.md", "v1"); + +// Quiesce: wait until all in-flight bytes, dispatches, pending results, and pipes settle. This is +// what makes negative assertions (AssertNotReceived) deterministic. +await host.QuiesceAsync(); + +bob.AssertReceived(n => n.DraftSaved("alice", "v1")); +carol.AssertNotReceived( + n => n.DraftSaved(Arg.Any(), Arg.Any())); + +Assert.That(host.Groups["doc-design.md"].Count, Is.EqualTo(2)); +``` + +## What you get + +| Surface | Purpose | +|---------|---------| +| `NexusTestHost.CreateAsync(…)` | Spins up an in-process server. Returns a host you `await using`. | +| `host.ConnectAsAsync(TestIdentity.Of(name, roles))` | Connects a client under a fake identity; returns a `NexusTestClient`. | +| `client.Server` / `client.Nexus` / `client.SessionId` | The strongly-typed proxy to call server methods, the client nexus instance, and the resolved session id. | +| `host.QuiesceAsync()` | Awaits until all transit bytes, dispatches, pending results, and open pipes settle. | +| `client.AssertReceived(expr, times)` / `AssertNotReceived(expr)` / `WaitFor(expr)` | Per-client callback assertions using LINQ expressions. | +| `host.AssertReceived(expr, times)` | Server-side: assert what the server received. | +| `Arg.Any()` / `Arg.Is(predicate)` | Argument matchers inside assertion expressions. | +| `host.Groups[name].Members` / `.Count` | Introspect live group membership. | +| `TestIdentity.Of(name, roles)` | Build a fake principal; roles feed your `OnAuthorize`. | +| `PipeRecording` | Observe byte traffic on a tapped pipe (`WaitForBytesAsync`, `ConsumedBytes`, …). | +| `StreamingExtensions` (`PipeUploadAsync` / `PipeDownloadAsync` / `ChannelPublishAsync` / `ChannelCollectAsync`) | Convenience helpers for pipe/channel-based methods. | +| `InProcessServerConfig` / `InProcessClientConfig` | The in-process transport, usable directly as just another NexNet transport. | + +## Notes + +- **Quiescence** is the load-bearing primitive. After driving traffic, `await host.QuiesceAsync()` + before asserting; negative assertions are only meaningful once the system is known to be idle. +- **Testing your own auth.** Connect with `TestIdentity.Of(name, roles)` and your real + `OnAuthorize` override runs against those roles. To test your own `OnAuthenticate` code instead, + leave the harness override unset and connect with a real token. +- **Time-sensitive logic.** This release does not yet provide deterministic time control, so tests + of TTL / reconnect / ping timing remain wall-clock-bound. Fake-time support is tracked upstream. + +See the [NexNet repository](https://github.com/Dtronix/NexNet) for full documentation. diff --git a/src/NexNet.Testing/Recording/Arg.cs b/src/NexNet.Testing/Recording/Arg.cs new file mode 100644 index 00000000..d38c3458 --- /dev/null +++ b/src/NexNet.Testing/Recording/Arg.cs @@ -0,0 +1,29 @@ +using System; + +namespace NexNet.Testing; + +/// +/// Sentinel matchers used inside the lambda passed to AssertReceived, +/// AssertNotReceived, and WaitFor. The calls return default(T) at +/// runtime; the harness's ExpressionParser recognizes them by their method handle +/// when walking the lambda body and translates them into wildcard / predicate matchers. +/// +public static class Arg +{ + /// + /// Matches any value of . Use as a placeholder when only some + /// arguments are interesting: n.M(42, Arg.Any<string>()). + /// + public static T Any() => default!; + + /// + /// Matches values of for which + /// returns true. The predicate is captured by ExpressionParser and invoked at + /// assertion time against each recorded argument value. + /// + public static T Is(Func predicate) + { + _ = predicate; // referenced so callers can pass without warning; consumed via expression tree + return default!; + } +} diff --git a/src/NexNet.Testing/Recording/ArgMatcher.cs b/src/NexNet.Testing/Recording/ArgMatcher.cs new file mode 100644 index 00000000..ab9774de --- /dev/null +++ b/src/NexNet.Testing/Recording/ArgMatcher.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; + +namespace NexNet.Testing.Recording; + +/// +/// One argument's matcher. Constructed by from an argument +/// expression inside an assertion lambda; tested against a deserialized recorded value at +/// assertion time. +/// +internal abstract class ArgMatcher +{ + /// Indicates whether satisfies this matcher. + public abstract bool Matches(object? actual); + + /// Human-readable rendering used in messages. + public abstract override string ToString(); + + public static ArgMatcher Wildcard(Type type) => new WildcardMatcher(type); + public static ArgMatcher Equality(object? expected) => new EqualityMatcher(expected); + public static ArgMatcher Predicate(Delegate predicate, Type argType) => new PredicateMatcher(predicate, argType); + + private sealed class WildcardMatcher : ArgMatcher + { + private readonly Type _type; + public WildcardMatcher(Type type) => _type = type; + public override bool Matches(object? actual) => true; + public override string ToString() => $"Arg.Any<{_type.Name}>()"; + } + + private sealed class EqualityMatcher : ArgMatcher + { + private readonly object? _expected; + public EqualityMatcher(object? expected) => _expected = expected; + + public override bool Matches(object? actual) + { + if (_expected is null) return actual is null; + return EqualityComparer.Default.Equals(_expected, actual); + } + + public override string ToString() => _expected switch + { + null => "null", + string s => $"\"{s}\"", + _ => _expected.ToString() ?? "null" + }; + } + + private sealed class PredicateMatcher : ArgMatcher + { + private readonly Delegate _predicate; + private readonly Type _argType; + + public PredicateMatcher(Delegate predicate, Type argType) + { + _predicate = predicate; + _argType = argType; + } + + public override bool Matches(object? actual) + { + // The predicate is typed Func; invoke via DynamicInvoke so we don't need + // to thread through the recorder. + object? result; + try + { + result = _predicate.DynamicInvoke(actual); + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null) + { + // DynamicInvoke wraps user-thrown exceptions; surface the original so the user + // sees their predicate's exception instead of a generic "no match". Previously + // this was swallowed silently and reported as `observed: 0`, which was + // indistinguishable from a real no-match and frustrating to diagnose. + throw new NexusAssertionException( + $"Arg.Is<{_argType.Name}>(predicate) threw {tie.InnerException.GetType().Name}: {tie.InnerException.Message}", + tie.InnerException); + } + return result is bool b && b; + } + + public override string ToString() => $"Arg.Is<{_argType.Name}>(predicate)"; + } +} diff --git a/src/NexNet.Testing/Recording/ArgumentDeserializer.cs b/src/NexNet.Testing/Recording/ArgumentDeserializer.cs new file mode 100644 index 00000000..432526ee --- /dev/null +++ b/src/NexNet.Testing/Recording/ArgumentDeserializer.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using MemoryPack; + +namespace NexNet.Testing.Recording; + +/// +/// Deserializes the raw argument bytes from an back into the +/// per-parameter values using the same MemoryPack-over-ValueTuple shape the source generator +/// uses at the call site. +/// +internal static class ArgumentDeserializer +{ + /// + /// Builds the open generic ValueTuple<...> type that the generator's call-site + /// serializer uses for the given method's serializable parameters. + /// + public static Type? GetTupleType(MethodInfo method) + { + var serializable = GetSerializableParameterTypes(method); + if (serializable.Length == 0) return null; + return MakeValueTupleType(serializable); + } + + /// + /// Deserializes into a per-parameter object array, indexed + /// positionally over the serializable parameters of . Returns an + /// empty array when the method has no serializable parameters. + /// + public static object?[] Deserialize(MethodInfo method, ReadOnlyMemory argsBytes) + { + var tupleType = GetTupleType(method); + if (tupleType is null) return Array.Empty(); + + // MemoryPackSerializer.Deserialize(ReadOnlySpan) is the public API; for an + // unknown closed generic we go through the non-generic Type overload. + var deserialized = MemoryPackSerializer.Deserialize(tupleType, argsBytes.Span); + if (deserialized is null) return Array.Empty(); + + var fields = tupleType.GetFields(BindingFlags.Public | BindingFlags.Instance); + var result = new object?[fields.Length]; + for (int i = 0; i < fields.Length; i++) + result[i] = fields[i].GetValue(deserialized); + return result; + } + + private static Type[] GetSerializableParameterTypes(MethodInfo method) + { + var list = new List(); + foreach (var p in method.GetParameters()) + { + if (IsSerializableParameter(p.ParameterType)) + list.Add(p.ParameterType); + } + return list.ToArray(); + } + + /// + /// Mirrors the source generator's exclusion list verbatim. The generator at + /// NexusDataExtractor.cs excludes these specific shapes from the serialized + /// ValueTuple; matching them here keeps the runtime deserializer in lockstep so future + /// generator additions need an explicit update in both places (rather than silently failing + /// because a new excluded namespace member slipped through a prefix-based filter). + /// + internal static bool IsSerializableParameter(Type t) + { + if (t.FullName == "System.Threading.CancellationToken") return false; + + if (t.FullName == "NexNet.Pipes.INexusDuplexPipe") return false; + if (t.FullName == "NexNet.Pipes.IRentedNexusDuplexPipe") return false; + + if (t.IsGenericType) + { + var def = t.GetGenericTypeDefinition(); + if (def.FullName == "NexNet.Pipes.INexusDuplexUnmanagedChannel`1") return false; + if (def.FullName == "NexNet.Pipes.INexusDuplexChannel`1") return false; + } + + return true; + } + + private static Type MakeValueTupleType(Type[] elementTypes) + { + return elementTypes.Length switch + { + 1 => typeof(ValueTuple<>).MakeGenericType(elementTypes), + 2 => typeof(ValueTuple<,>).MakeGenericType(elementTypes), + 3 => typeof(ValueTuple<,,>).MakeGenericType(elementTypes), + 4 => typeof(ValueTuple<,,,>).MakeGenericType(elementTypes), + 5 => typeof(ValueTuple<,,,,>).MakeGenericType(elementTypes), + 6 => typeof(ValueTuple<,,,,,>).MakeGenericType(elementTypes), + 7 => typeof(ValueTuple<,,,,,,>).MakeGenericType(elementTypes), + _ => throw new NotSupportedException( + $"Method with {elementTypes.Length} serializable parameters is not supported by the test harness yet.") + }; + } +} diff --git a/src/NexNet.Testing/Recording/ExpressionParser.cs b/src/NexNet.Testing/Recording/ExpressionParser.cs new file mode 100644 index 00000000..eb3d03e0 --- /dev/null +++ b/src/NexNet.Testing/Recording/ExpressionParser.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; + +namespace NexNet.Testing.Recording; + +/// +/// Resolves an assertion lambda like n => n.M(42, Arg.Any<string>()) into a +/// callable contract: the target on the interface and a list of +/// values, one per parameter. +/// +internal static class ExpressionParser +{ + private static readonly MethodInfo ArgAnyOpenGeneric = typeof(Arg).GetMethod( + nameof(Arg.Any), + BindingFlags.Public | BindingFlags.Static)!; + + private static readonly MethodInfo ArgIsOpenGeneric = typeof(Arg).GetMethod( + nameof(Arg.Is), + BindingFlags.Public | BindingFlags.Static)!; + + public static (MethodInfo Method, IReadOnlyList Matchers) Parse( + Expression> expression) + { + if (expression.Body is not MethodCallExpression call) + throw new ArgumentException( + "Assertion expression must be a single method call on the interface, " + + $"got: {expression.Body.NodeType}", + nameof(expression)); + + var parameters = call.Method.GetParameters(); + var matchers = new ArgMatcher[parameters.Length]; + + for (int i = 0; i < call.Arguments.Count; i++) + { + matchers[i] = ResolveArgument(call.Arguments[i], parameters[i].ParameterType); + } + + return (call.Method, matchers); + } + + private static ArgMatcher ResolveArgument(Expression argExpr, Type parameterType) + { + // Strip any cast / convert nodes the compiler inserted for boxing or co/contra-variance. + while (argExpr is UnaryExpression u + && (u.NodeType is ExpressionType.Convert or ExpressionType.ConvertChecked)) + { + argExpr = u.Operand; + } + + if (argExpr is MethodCallExpression mc) + { + var def = mc.Method.IsGenericMethod ? mc.Method.GetGenericMethodDefinition() : mc.Method; + + if (def == ArgAnyOpenGeneric) + { + return ArgMatcher.Wildcard(parameterType); + } + + if (def == ArgIsOpenGeneric) + { + // The predicate sits inside the call's first argument as a quoted lambda. + if (mc.Arguments.Count != 1) + throw new ArgumentException("Arg.Is must take a single predicate argument."); + + var predicateExpr = mc.Arguments[0]; + // Compile to delegate. Captured locals are honored automatically. + var compiled = Expression.Lambda(predicateExpr).Compile().DynamicInvoke(); + if (compiled is not Delegate del) + throw new ArgumentException("Arg.Is predicate did not resolve to a delegate."); + return ArgMatcher.Predicate(del, parameterType); + } + } + + // Anything else (constants, captured locals, expressions) is reduced to a value via + // Expression.Lambda(...).Compile() and matched with equality. + var value = Expression.Lambda(argExpr).Compile().DynamicInvoke(); + return ArgMatcher.Equality(value); + } +} diff --git a/src/NexNet.Testing/Recording/InvocationRecord.cs b/src/NexNet.Testing/Recording/InvocationRecord.cs new file mode 100644 index 00000000..229362fd --- /dev/null +++ b/src/NexNet.Testing/Recording/InvocationRecord.cs @@ -0,0 +1,30 @@ +using System; +using System.Reflection; + +namespace NexNet.Testing.Recording; + +/// +/// One row captured by : which method id the session received +/// and the raw serialized argument bytes. The bytes stay serialized until an assertion needs +/// to inspect them; this keeps the dispatch hot path zero-cost beyond a small allocation per +/// recorded invocation. +/// +internal sealed class InvocationRecord +{ + public ushort MethodId { get; } + public ReadOnlyMemory Arguments { get; } + + /// + /// Method id resolved to a CLR , when known. Populated by the + /// host's method-id map at recorder construction time; null if the method id does not + /// belong to a known nexus interface. + /// + public MethodInfo? Method { get; } + + public InvocationRecord(ushort methodId, ReadOnlyMemory arguments, MethodInfo? method) + { + MethodId = methodId; + Arguments = arguments; + Method = method; + } +} diff --git a/src/NexNet.Testing/Recording/InvocationRecorder.cs b/src/NexNet.Testing/Recording/InvocationRecorder.cs new file mode 100644 index 00000000..62f466cb --- /dev/null +++ b/src/NexNet.Testing/Recording/InvocationRecorder.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace NexNet.Testing.Recording; + +/// +/// Append-only, snapshot-readable log of invocations seen on a single session. Writers (the +/// session's interceptor) take the internal lock for the duration of the append; readers +/// (assertions) take it briefly to snapshot the current list and walk a stable copy. The +/// signal exposed via wakes WaitFor when a new record +/// arrives. +/// +internal sealed class InvocationRecorder +{ + private readonly object _gate = new(); + private readonly List _records = new(); + private TaskCompletionSource _changeSignal = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Appends a record and signals any waiters that the recorder has changed. Returns the + /// 1-based index of the inserted record (purely informational). + /// + public int Append(InvocationRecord record) + { + TaskCompletionSource? signalToFire; + int index; + lock (_gate) + { + _records.Add(record); + index = _records.Count; + signalToFire = _changeSignal; + _changeSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + signalToFire.TrySetResult(); + return index; + } + + /// Snapshot of the currently-recorded invocations. Safe to enumerate. + public IReadOnlyList Snapshot() + { + lock (_gate) + { + return _records.ToArray(); + } + } + + /// + /// Returns a task that completes the next time a record is appended. Designed for + /// long-poll style waiters; callers should re-check the snapshot after the task completes + /// because multiple concurrent waiters all wake on the same signal. + /// + public Task WaitForChangeAsync(CancellationToken cancellationToken = default) + { + TaskCompletionSource signal; + lock (_gate) + { + signal = _changeSignal; + } + + if (!cancellationToken.CanBeCanceled) + return signal.Task; + + return signal.Task.WaitAsync(cancellationToken); + } + + /// Current record count. Cheap; takes the lock for a single read. + public int Count + { + get + { + lock (_gate) + { + return _records.Count; + } + } + } +} diff --git a/src/NexNet.Testing/Recording/MethodIdMap.cs b/src/NexNet.Testing/Recording/MethodIdMap.cs new file mode 100644 index 00000000..13c31c3d --- /dev/null +++ b/src/NexNet.Testing/Recording/MethodIdMap.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace NexNet.Testing.Recording; + +/// +/// Mapping from to the ushort method id assigned by the source +/// generator. Mirrors the generator's id-assignment at runtime so the assertion API resolves the +/// same id the generator burned into the proxy/nexus types. +/// +/// +/// +/// Parity rules — these MUST stay in lockstep with NexusDataExtractor.ExtractInterfaceData / +/// AssignMethodIds in the generator: +/// +/// +/// Directly-declared methods first (metadata-token order, which matches source declaration +/// order), then each inherited interface's directly-declared methods. +/// Inherited interfaces ordered by a C#-style display name (ordinal), matching the +/// generator's AllInterfaces.OrderBy(i => i.ToDisplayString(), StringComparer.Ordinal). +/// Type.FullName is deliberately NOT used: it diverges from ToDisplayString() for +/// generic interfaces (CLR `1[[System.Guid, ...]] vs C# <System.Guid>) and for +/// nested interfaces (+ vs .), which would shift ids on those shapes. +/// Methods marked [NexusMethod(Ignore = true)] are excluded entirely — the generator +/// filters them out before assigning ids, so they receive no id and never reserve a slot. +/// Explicit values take precedence; the remaining +/// methods receive sequential ids that skip the explicitly-reserved set. +/// +/// +/// AOT/trimming caveat: metadata-token order remains stable under standard runtimes today; if a +/// future host re-orders GetMethods() output, assertions surface a clear "method not +/// recorded" failure rather than silently matching the wrong method. See MethodIdMapTests +/// for the regression tests that lock in the demo-interface layout, ignored-method filtering, and +/// generic-inherited-interface ordering. +/// +/// +internal static class MethodIdMap +{ + public static Dictionary Build(Type interfaceType) + { + var methods = CollectDeclaredMethods(interfaceType); + + var explicitIds = new HashSet(); + foreach (var m in methods) + { + var attr = m.GetCustomAttribute(); + if (attr is { MethodId: > 0 }) explicitIds.Add(attr.MethodId); + } + + var result = new Dictionary(); + ushort next = 0; + foreach (var m in methods) + { + var attr = m.GetCustomAttribute(); + ushort id; + if (attr is { MethodId: > 0 }) + { + id = attr.MethodId; + } + else + { + while (explicitIds.Contains(next)) next++; + id = next++; + } + result[m] = id; + } + return result; + } + + /// + /// Enumerates methods in the same order the generator's AssignMethodIds uses: + /// directly-declared methods first, then each inherited interface's directly-declared methods, + /// with inherited interfaces ordered by C#-style display name (ordinal). Methods marked + /// [NexusMethod(Ignore = true)] are excluded, matching the generator's pre-assignment + /// filter. + /// + private static IReadOnlyList CollectDeclaredMethods(Type interfaceType) + { + const BindingFlags directOnly = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + + var ordered = new List(); + ordered.AddRange(interfaceType.GetMethods(directOnly).Where(NotIgnored)); + + var inherited = interfaceType.GetInterfaces() + .OrderBy(GeneratorOrderingKey, StringComparer.Ordinal); + foreach (var iface in inherited) + ordered.AddRange(iface.GetMethods(directOnly).Where(NotIgnored)); + + return ordered; + } + + private static bool NotIgnored(MethodInfo method) + => method.GetCustomAttribute() is not { Ignore: true }; + + /// + /// Builds a C#-style display name for whose ordinal ordering matches + /// Roslyn's INamedTypeSymbol.ToDisplayString() — the generator's inherited-interface + /// sort key. Handles namespaces, nested types ('.'-joined), generic arguments (rendered in + /// <>, each recursively and namespace-qualified), arrays, , + /// and the C# keyword spellings of the built-in special types. + /// + /// + /// Generic args declared on an enclosing type (the rare Outer<T>.IInner nesting) + /// are rendered at the innermost level rather than distributed across the nesting chain; this + /// is a benign deviation for a code shape that does not occur on NexNet RPC interfaces. + /// + private static string GeneratorOrderingKey(Type type) + { + var sb = new StringBuilder(); + AppendDisplayName(sb, type); + return sb.ToString(); + } + + private static void AppendDisplayName(StringBuilder sb, Type type) + { + if (CSharpKeyword(type) is { } keyword) + { + sb.Append(keyword); + return; + } + + if (type.IsArray) + { + AppendDisplayName(sb, type.GetElementType()!); + sb.Append('[').Append(',', type.GetArrayRank() - 1).Append(']'); + return; + } + + var underlying = Nullable.GetUnderlyingType(type); + if (underlying is not null) + { + AppendDisplayName(sb, underlying); + sb.Append('?'); + return; + } + + if (type.IsNested && type.DeclaringType is { } declaring) + { + AppendDisplayName(sb, declaring); + sb.Append('.'); + } + else if (!string.IsNullOrEmpty(type.Namespace)) + { + sb.Append(type.Namespace).Append('.'); + } + + var name = type.Name; + var tick = name.IndexOf('`'); + if (tick >= 0) name = name.Substring(0, tick); + sb.Append(name); + + if (type.IsGenericType) + { + var args = type.GetGenericArguments(); + sb.Append('<'); + for (int i = 0; i < args.Length; i++) + { + if (i > 0) sb.Append(", "); + AppendDisplayName(sb, args[i]); + } + sb.Append('>'); + } + } + + /// + /// Returns the C# keyword spelling for a built-in special type (matching Roslyn's + /// UseSpecialTypes display option), or null for everything else. Compares by exact + /// type to avoid the pitfall where an enum reports its underlying + /// primitive's type code. + /// + private static string? CSharpKeyword(Type type) + { + if (type == typeof(bool)) return "bool"; + if (type == typeof(byte)) return "byte"; + if (type == typeof(sbyte)) return "sbyte"; + if (type == typeof(char)) return "char"; + if (type == typeof(short)) return "short"; + if (type == typeof(ushort)) return "ushort"; + if (type == typeof(int)) return "int"; + if (type == typeof(uint)) return "uint"; + if (type == typeof(long)) return "long"; + if (type == typeof(ulong)) return "ulong"; + if (type == typeof(float)) return "float"; + if (type == typeof(double)) return "double"; + if (type == typeof(decimal)) return "decimal"; + if (type == typeof(string)) return "string"; + if (type == typeof(object)) return "object"; + if (type == typeof(void)) return "void"; + return null; + } +} diff --git a/src/NexNet.Testing/Recording/RecorderAssertions.cs b/src/NexNet.Testing/Recording/RecorderAssertions.cs new file mode 100644 index 00000000..c8c1ecab --- /dev/null +++ b/src/NexNet.Testing/Recording/RecorderAssertions.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace NexNet.Testing.Recording; + +/// +/// Shared assertion logic used by both the host-side (server recorder) and the client-side +/// (per-client recorder) assertion APIs. Holds a method-id map cache keyed by interface type. +/// Stateless apart from that cache — instances can be reused freely across calls. +/// +internal sealed class RecorderAssertions +{ + private readonly InvocationRecorder _recorder; + private readonly Dictionary> _methodIdMapsByInterface = new(); + + public RecorderAssertions(InvocationRecorder recorder) + { + _recorder = recorder; + } + + public void AssertReceived(Expression> expression, int times) + { + var matches = FindMatches(expression); + if (matches.Count != times) + throw new NexusAssertionException( + BuildMismatchMessage(expression, expected: times, observed: matches.Count)); + } + + public void AssertNotReceived(Expression> expression) + { + var matches = FindMatches(expression); + if (matches.Count > 0) + throw new NexusAssertionException( + BuildMismatchMessage(expression, expected: 0, observed: matches.Count)); + } + + public async Task WaitFor( + Expression> expression, + TimeSpan? timeout) + { + // Monotonic clock so the wait survives wall-clock jumps (DST, NTP correction). + var totalMs = (long)(timeout ?? TimeSpan.FromSeconds(5)).TotalMilliseconds; + var startTicks = Environment.TickCount64; + while (true) + { + if (FindMatches(expression).Count > 0) return; + var elapsedMs = Environment.TickCount64 - startTicks; + var remainingMs = totalMs - elapsedMs; + if (remainingMs <= 0) + throw new TimeoutException( + $"WaitFor timed out after {totalMs} ms."); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(remainingMs)); + try { await _recorder.WaitForChangeAsync(cts.Token).ConfigureAwait(false); } + catch (OperationCanceledException) { /* re-check on next loop iteration */ } + } + } + + private Dictionary GetMethodIdMap(Type interfaceType) + { + lock (_methodIdMapsByInterface) + { + if (!_methodIdMapsByInterface.TryGetValue(interfaceType, out var map)) + { + map = MethodIdMap.Build(interfaceType); + _methodIdMapsByInterface[interfaceType] = map; + } + return map; + } + } + + private IReadOnlyList FindMatches(Expression> expression) + { + var (method, matchers) = ExpressionParser.Parse(expression); + var idMap = GetMethodIdMap(typeof(TInterface)); + if (!idMap.TryGetValue(method, out var methodId)) + return Array.Empty(); + + var serializableMatchers = SelectSerializableMatchers(method, matchers); + + var matches = new List(); + foreach (var record in _recorder.Snapshot()) + { + if (record.MethodId != methodId) continue; + if (!ArgsMatch(method, serializableMatchers, record.Arguments)) continue; + matches.Add(record); + } + return matches; + } + + private static IReadOnlyList SelectSerializableMatchers( + MethodInfo method, IReadOnlyList allMatchers) + { + var parameters = method.GetParameters(); + var result = new List(allMatchers.Count); + for (int i = 0; i < parameters.Length; i++) + { + if (ArgumentDeserializer.IsSerializableParameter(parameters[i].ParameterType)) + result.Add(allMatchers[i]); + } + return result; + } + + private static bool ArgsMatch(MethodInfo method, IReadOnlyList matchers, ReadOnlyMemory argsBytes) + { + if (matchers.Count == 0) return true; + var values = ArgumentDeserializer.Deserialize(method, argsBytes); + if (values.Length != matchers.Count) return false; + for (int i = 0; i < values.Length; i++) + if (!matchers[i].Matches(values[i])) return false; + return true; + } + + private string BuildMismatchMessage( + Expression> expression, + int expected, + int observed) + { + var sb = new StringBuilder(); + sb.Append("Expected ").Append(expected).Append(" invocation(s) matching '") + .Append(expression).Append("', observed ").Append(observed).Append('.'); + + var recent = _recorder.Snapshot().Reverse().Take(10).ToArray(); + if (recent.Length > 0) + { + // Build a methodId -> MethodInfo reverse map so we can deserialize the arg bytes + // and render the actual recorded values alongside the methodIds. Falls back to the + // bare methodId when no MethodInfo is known (e.g., a method id from a different + // interface than the one currently being asserted against). + var idToMethod = new Dictionary(); + var interfaceMap = GetMethodIdMap(typeof(TInterface)); + foreach (var (method, id) in interfaceMap) + idToMethod[id] = method; + + sb.Append(" Recent recorded invocations (most-recent first):"); + for (int i = 0; i < recent.Length; i++) + { + var record = recent[i]; + sb.Append(' '); + sb.Append('[').Append(i).Append("] "); + if (idToMethod.TryGetValue(record.MethodId, out var method)) + { + sb.Append(method.Name).Append('('); + var argText = TryRenderArgs(method, record.Arguments); + sb.Append(argText); + sb.Append(')'); + } + else + { + sb.Append("#").Append(record.MethodId).Append("(unknown-method)"); + } + if (i < recent.Length - 1) sb.Append(';'); + } + } + return sb.ToString(); + } + + private static string TryRenderArgs(MethodInfo method, ReadOnlyMemory argsBytes) + { + try + { + var values = ArgumentDeserializer.Deserialize(method, argsBytes); + if (values.Length == 0) return string.Empty; + return string.Join(", ", values.Select(FormatArgValue)); + } + catch (Exception ex) + { + // A deserialize failure here is not the user's main bug — it's a side-effect of + // surfacing the diagnostic. Show the exception type instead of throwing. + return $""; + } + } + + private static string FormatArgValue(object? value) => value switch + { + null => "null", + string s => "\"" + s + "\"", + _ => value.ToString() ?? value.GetType().Name, + }; +} diff --git a/src/NexNet.Testing/Recording/TestInvocationInterceptor.cs b/src/NexNet.Testing/Recording/TestInvocationInterceptor.cs new file mode 100644 index 00000000..d15e26c5 --- /dev/null +++ b/src/NexNet.Testing/Recording/TestInvocationInterceptor.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading.Tasks; +using NexNet.Internals; +using NexNet.Messages; +using NexNet.Testing.Quiescence; + +namespace NexNet.Testing.Recording; + +/// +/// Harness implementation of . Captures every incoming +/// invocation into the supplied and tracks the +/// inDispatch quiescence counter for the duration of the invocation. +/// +internal sealed class TestInvocationInterceptor : IInvocationInterceptor +{ + private readonly InvocationRecorder _recorder; + private readonly QuiescenceCounters _counters; + private readonly QuiescenceTracker _tracker; + + public TestInvocationInterceptor( + InvocationRecorder recorder, + QuiescenceCounters counters, + QuiescenceTracker tracker) + { + _recorder = recorder; + _counters = counters; + _tracker = tracker; + } + + public async ValueTask WrapAsync(InvocationMessage message, Func invoke) + { + // Copy the args bytes eagerly. The pooled InvocationMessage will be returned to its + // pool when dispatch completes, so we cannot retain a reference into it. + var argsCopy = message.Arguments.ToArray(); + _recorder.Append(new InvocationRecord(message.MethodId, argsCopy, method: null)); + + _counters.EnterDispatch(); + _tracker.SignalChange(); + try + { + await invoke().ConfigureAwait(false); + } + finally + { + _counters.ExitDispatch(); + _tracker.SignalChange(); + } + } +} diff --git a/src/NexNet.Testing/Streaming/ChannelRecording.cs b/src/NexNet.Testing/Streaming/ChannelRecording.cs new file mode 100644 index 00000000..bf3e0851 --- /dev/null +++ b/src/NexNet.Testing/Streaming/ChannelRecording.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace NexNet.Testing.Streaming; + +/// +/// Observation surface for a tapped channel of items. Lives next to +/// as the typed counterpart: byte-level taps capture pipe traffic; +/// channel taps capture deserialized item traffic, which is the level of detail tests usually +/// want when asserting on channel-based methods. +/// +/// Item type produced or consumed on the channel. +/// +/// Internal in v1: the TapChannel<T> producer that would hand a populated instance to +/// callers was deferred (see review.md), so there is no public way to obtain one wired to a live +/// channel. Kept internal (rather than shipped as orphaned public surface) until that producer +/// exists — promoting it later is a non-breaking change, demoting a shipped public type is not. +/// +internal sealed class ChannelRecording +{ + private readonly object _gate = new(); + private readonly List _items = new(); + private bool _completed; + private Exception? _faultedWith; + private TaskCompletionSource _itemSignal = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _completionSignal = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Snapshot of items recorded so far. + public IReadOnlyList Items + { + get { lock (_gate) { return _items.ToArray(); } } + } + + /// Whether the channel has been completed. + public bool IsCompleted + { + get { lock (_gate) { return _completed; } } + } + + /// Exception that closed the channel, if any. + public Exception? FaultedWith + { + get { lock (_gate) { return _faultedWith; } } + } + + /// Returns a task that completes once items are recorded. + public Task WaitForCountAsync(int minItems, CancellationToken cancellationToken = default) + => WaitForCountCore(minItems, cancellationToken); + + private async Task WaitForCountCore(int minItems, CancellationToken cancellationToken) + { + while (true) + { + TaskCompletionSource signal; + lock (_gate) + { + if (_items.Count >= minItems) return; + signal = _itemSignal; + } + var task = cancellationToken.CanBeCanceled ? signal.Task.WaitAsync(cancellationToken) : signal.Task; + await task.ConfigureAwait(false); + } + } + + /// Returns a task that completes when the channel enters the completed state. + public Task WaitForCompletionAsync(CancellationToken cancellationToken = default) + { + TaskCompletionSource signal; + lock (_gate) + { + if (_completed) return Task.CompletedTask; + signal = _completionSignal; + } + return cancellationToken.CanBeCanceled ? signal.Task.WaitAsync(cancellationToken) : signal.Task; + } + + /// Records an item and signals any pending count waiters. + public void RecordItem(T item) + { + TaskCompletionSource toFire; + lock (_gate) + { + _items.Add(item); + toFire = _itemSignal; + _itemSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + toFire.TrySetResult(); + } + + /// Records channel completion with an optional fault. + public void RecordCompletion(Exception? error) + { + TaskCompletionSource toFire; + lock (_gate) + { + if (_completed) return; + _completed = true; + _faultedWith = error; + toFire = _completionSignal; + } + toFire.TrySetResult(); + } +} diff --git a/src/NexNet.Testing/Streaming/PipeRecording.cs b/src/NexNet.Testing/Streaming/PipeRecording.cs new file mode 100644 index 00000000..5ecbab9b --- /dev/null +++ b/src/NexNet.Testing/Streaming/PipeRecording.cs @@ -0,0 +1,131 @@ +using System; +using System.Buffers; +using System.Threading; +using System.Threading.Tasks; + +namespace NexNet.Testing.Streaming; + +/// +/// Observation surface for a tapped duplex pipe. The harness's TestPipeFactory creates +/// one of these per wrapped pipe; assertions read its current bytes and lifecycle state, and +/// can await activity via and . +/// +public sealed class PipeRecording +{ + private readonly object _gate = new(); + private readonly ArrayBufferWriter _consumed = new(); + private readonly ArrayBufferWriter _written = new(); + private bool _completed; + private Exception? _faultedWith; + private TaskCompletionSource _bytesSignal = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _completionSignal = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Bytes the local handler has consumed (advanced past) from the pipe. + public ReadOnlyMemory ConsumedBytes + { + get + { + lock (_gate) { return _consumed.WrittenMemory.ToArray(); } + } + } + + /// Bytes the local handler has flushed into the pipe's output. + public ReadOnlyMemory WrittenBytes + { + get + { + lock (_gate) { return _written.WrittenMemory.ToArray(); } + } + } + + /// Whether the underlying pipe has reached the completed state. + public bool IsCompleted + { + get { lock (_gate) { return _completed; } } + } + + /// Exception that closed the pipe, if any. + public Exception? FaultedWith + { + get { lock (_gate) { return _faultedWith; } } + } + + /// + /// Returns a task that completes when at least total bytes + /// have been consumed-or-written (whichever side the caller is interested in is up to the + /// caller — this signal fires on either). + /// + public Task WaitForBytesAsync(int minBytes, CancellationToken cancellationToken = default) + { + while (true) + { + TaskCompletionSource signal; + lock (_gate) + { + if (_consumed.WrittenCount >= minBytes || _written.WrittenCount >= minBytes) + return Task.CompletedTask; + signal = _bytesSignal; + } + + return AwaitAndRetry(signal, minBytes, cancellationToken); + } + } + + private async Task AwaitAndRetry(TaskCompletionSource signal, int minBytes, CancellationToken ct) + { + var task = ct.CanBeCanceled ? signal.Task.WaitAsync(ct) : signal.Task; + await task.ConfigureAwait(false); + await WaitForBytesAsync(minBytes, ct).ConfigureAwait(false); + } + + /// Returns a task that completes when the pipe enters the completed state. + public Task WaitForCompletionAsync(CancellationToken cancellationToken = default) + { + TaskCompletionSource signal; + lock (_gate) + { + if (_completed) return Task.CompletedTask; + signal = _completionSignal; + } + return cancellationToken.CanBeCanceled ? signal.Task.WaitAsync(cancellationToken) : signal.Task; + } + + internal void RecordConsumed(ReadOnlySpan slice) + { + TaskCompletionSource toFire; + lock (_gate) + { + _consumed.Write(slice); + toFire = _bytesSignal; + _bytesSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + toFire.TrySetResult(); + } + + internal void RecordWritten(ReadOnlySpan slice) + { + TaskCompletionSource toFire; + lock (_gate) + { + _written.Write(slice); + toFire = _bytesSignal; + _bytesSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + toFire.TrySetResult(); + } + + internal void RecordCompletion(Exception? error) + { + TaskCompletionSource toFire; + lock (_gate) + { + if (_completed) return; + _completed = true; + _faultedWith = error; + toFire = _completionSignal; + } + toFire.TrySetResult(); + } +} diff --git a/src/NexNet.Testing/Streaming/StreamingExtensions.cs b/src/NexNet.Testing/Streaming/StreamingExtensions.cs new file mode 100644 index 00000000..d2fb6a05 --- /dev/null +++ b/src/NexNet.Testing/Streaming/StreamingExtensions.cs @@ -0,0 +1,114 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Pipes; + +namespace NexNet.Testing.Streaming; + +/// +/// Convenience extensions over for the most common +/// test-side patterns: pushing a buffer of bytes, draining a buffer of bytes, publishing a +/// fixed batch of typed items, and collecting all typed items until the channel closes. +/// +/// +/// The plan referred to these as PipeUpload / PipeDownload / ChannelPublish +/// / ChannelCollect. They are thin wrappers — each replaces 3-4 boilerplate lines (await +/// ReadyTask, do work on the writer/reader, complete) with a single call so tests stay focused +/// on the assertion rather than the plumbing. +/// +public static class StreamingExtensions +{ + /// + /// Awaits , writes every byte in + /// to the pipe's output, then completes the writer. Equivalent to: + /// await pipe.ReadyTask; await pipe.Output.WriteAsync(bytes); await pipe.CompleteAsync();. + /// + public static async ValueTask PipeUploadAsync( + this IRentedNexusDuplexPipe pipe, + ReadOnlyMemory bytes, + CancellationToken cancellationToken = default) + { + if (pipe is null) throw new ArgumentNullException(nameof(pipe)); + await pipe.ReadyTask.ConfigureAwait(false); + if (!bytes.IsEmpty) + await pipe.Output.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await pipe.CompleteAsync().ConfigureAwait(false); + } + + /// + /// Awaits , reads all bytes from the pipe until the + /// writer completes, then returns the concatenated buffer. Useful for asserting on the full + /// payload of a download method. + /// + public static async ValueTask PipeDownloadAsync( + this IRentedNexusDuplexPipe pipe, + CancellationToken cancellationToken = default) + { + if (pipe is null) throw new ArgumentNullException(nameof(pipe)); + await pipe.ReadyTask.ConfigureAwait(false); + + using var ms = new MemoryStream(); + while (true) + { + var result = await pipe.Input.ReadAsync(cancellationToken).ConfigureAwait(false); + foreach (var segment in result.Buffer) + { + ms.Write(segment.Span); + } + pipe.Input.AdvanceTo(result.Buffer.End); + if (result.IsCompleted) + break; + } + return ms.ToArray(); + } + + /// + /// Acquires a typed channel writer over the pipe and writes every item in + /// , then completes the writer. Useful for invoking a server method + /// that takes a stream of typed inputs. + /// + public static async ValueTask ChannelPublishAsync( + this IRentedNexusDuplexPipe pipe, + IEnumerable items, + CancellationToken cancellationToken = default) + { + if (pipe is null) throw new ArgumentNullException(nameof(pipe)); + if (items is null) throw new ArgumentNullException(nameof(items)); + + var writer = await pipe.GetChannelWriter().ConfigureAwait(false); + try + { + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + await writer.WriteAsync(item, cancellationToken).ConfigureAwait(false); + } + } + finally + { + await writer.CompleteAsync().ConfigureAwait(false); + } + } + + /// + /// Acquires a typed channel reader over the pipe, drains every item until the writer + /// completes, and returns the list of received items. + /// + public static async ValueTask> ChannelCollectAsync( + this IRentedNexusDuplexPipe pipe, + CancellationToken cancellationToken = default) + { + if (pipe is null) throw new ArgumentNullException(nameof(pipe)); + + var reader = await pipe.GetChannelReader().ConfigureAwait(false); + var collected = new List(); + await foreach (var item in reader.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + collected.Add(item); + } + return collected; + } +} diff --git a/src/NexNet.Testing/Streaming/TappedNexusDuplexPipe.cs b/src/NexNet.Testing/Streaming/TappedNexusDuplexPipe.cs new file mode 100644 index 00000000..d0b0e73a --- /dev/null +++ b/src/NexNet.Testing/Streaming/TappedNexusDuplexPipe.cs @@ -0,0 +1,42 @@ +using System; +using System.IO.Pipelines; +using System.Threading.Tasks; +using NexNet.Pipes; + +namespace NexNet.Testing.Streaming; + +/// +/// wrapper that exposes a tapping reader/writer pair to the +/// handler while delegating every other interface member to the inner pipe. The pipe manager's +/// internal active-pipe registry continues to hold the underlying pipe, so incoming-data +/// routing is unaffected. +/// +internal sealed class TappedNexusDuplexPipe : INexusDuplexPipe +{ + private readonly INexusDuplexPipe _inner; + private readonly TappingPipeReader _reader; + private readonly TappingPipeWriter _writer; + + public PipeRecording Recording { get; } + + public TappedNexusDuplexPipe(INexusDuplexPipe inner) + { + _inner = inner; + Recording = new PipeRecording(); + _reader = new TappingPipeReader(_inner.Input, Recording); + _writer = new TappingPipeWriter(_inner.Output, Recording); + // Completion forwarding is owned by TestPipeFactory.Track, which bridges + // CompleteTask -> Recording.RecordCompletion + ClosePipe in one place. Registering a + // second continuation here would race-free-but-wasteful-double-invoke; rely on the + // factory's continuation instead. + } + + public PipeReader Input => _reader; + public PipeWriter Output => _writer; + public ushort Id => _inner.Id; + public Task ReadyTask => _inner.ReadyTask; + public Task CompleteTask => _inner.CompleteTask; + public ValueTask CompleteAsync() => _inner.CompleteAsync(); + NexusPipeWriter INexusDuplexPipe.WriterCore => _inner.WriterCore; + NexusPipeReader INexusDuplexPipe.ReaderCore => _inner.ReaderCore; +} diff --git a/src/NexNet.Testing/Streaming/TappingPipeReader.cs b/src/NexNet.Testing/Streaming/TappingPipeReader.cs new file mode 100644 index 00000000..77779cda --- /dev/null +++ b/src/NexNet.Testing/Streaming/TappingPipeReader.cs @@ -0,0 +1,107 @@ +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; + +namespace NexNet.Testing.Streaming; + +/// +/// wrapper that records the slice of bytes the handler commits past +/// each time it calls AdvanceTo. "What the handler saw" is the consumed slice rather than the +/// visible buffer because peek-without-consume is a normal pattern. +/// +internal sealed class TappingPipeReader : PipeReader +{ + private readonly PipeReader _inner; + private readonly PipeRecording _recording; + private ReadOnlySequence _lastBuffer; + private bool _hasLastBuffer; + + public TappingPipeReader(PipeReader inner, PipeRecording recording) + { + _inner = inner; + _recording = recording; + } + + public override void AdvanceTo(SequencePosition consumed) + { + RecordConsumedSlice(consumed); + _inner.AdvanceTo(consumed); + } + + public override void AdvanceTo(SequencePosition consumed, SequencePosition examined) + { + RecordConsumedSlice(consumed); + _inner.AdvanceTo(consumed, examined); + } + + public override void CancelPendingRead() => _inner.CancelPendingRead(); + + public override void Complete(Exception? exception = null) + { + _recording.RecordCompletion(exception); + _inner.Complete(exception); + } + + public override ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + var task = _inner.ReadAsync(cancellationToken); + return task.IsCompletedSuccessfully ? new ValueTask(StoreBuffer(task.Result)) : Wait(task); + } + + private async ValueTask Wait(ValueTask task) + { + var result = await task.ConfigureAwait(false); + return StoreBuffer(result); + } + + public override bool TryRead(out ReadResult result) + { + if (_inner.TryRead(out result)) + { + result = StoreBuffer(result); + return true; + } + return false; + } + + private ReadResult StoreBuffer(ReadResult result) + { + _lastBuffer = result.Buffer; + _hasLastBuffer = true; + if (result.IsCompleted) + _recording.RecordCompletion(null); + return result; + } + + private void RecordConsumedSlice(SequencePosition consumed) + { + if (!_hasLastBuffer) + return; + + var slice = _lastBuffer.Slice(0, consumed); + if (slice.Length == 0) + return; + + if (slice.IsSingleSegment) + { + _recording.RecordConsumed(slice.FirstSpan); + } + else + { + var rented = ArrayPool.Shared.Rent((int)slice.Length); + try + { + slice.CopyTo(rented); + _recording.RecordConsumed(rented.AsSpan(0, (int)slice.Length)); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + _hasLastBuffer = false; + } +} diff --git a/src/NexNet.Testing/Streaming/TappingPipeWriter.cs b/src/NexNet.Testing/Streaming/TappingPipeWriter.cs new file mode 100644 index 00000000..ff022082 --- /dev/null +++ b/src/NexNet.Testing/Streaming/TappingPipeWriter.cs @@ -0,0 +1,60 @@ +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; + +namespace NexNet.Testing.Streaming; + +/// +/// wrapper that records the bytes the handler advances past in the +/// writer's buffer. We tap at rather than at +/// because the visible memory is overwritten as the writer recycles segments; the +/// just-advanced range is the only window we are guaranteed to read correctly. +/// +internal sealed class TappingPipeWriter : PipeWriter +{ + private readonly PipeWriter _inner; + private readonly PipeRecording _recording; + private Memory _lastMemory; + + public TappingPipeWriter(PipeWriter inner, PipeRecording recording) + { + _inner = inner; + _recording = recording; + } + + public override void Advance(int bytes) + { + if (bytes > 0 && bytes <= _lastMemory.Length) + _recording.RecordWritten(_lastMemory.Span.Slice(0, bytes)); + _lastMemory = Memory.Empty; + _inner.Advance(bytes); + } + + public override Memory GetMemory(int sizeHint = 0) + { + _lastMemory = _inner.GetMemory(sizeHint); + return _lastMemory; + } + + public override Span GetSpan(int sizeHint = 0) + { + // The tap can only record the contiguous Memory; falling back to Span loses the + // ability to record, so we re-request as Memory. PipeWriter implementations are free + // to satisfy GetSpan from the same backing buffer. + _lastMemory = _inner.GetMemory(sizeHint); + return _lastMemory.Span; + } + + public override void CancelPendingFlush() => _inner.CancelPendingFlush(); + + public override void Complete(Exception? exception = null) + { + _recording.RecordCompletion(exception); + _inner.Complete(exception); + } + + public override ValueTask FlushAsync(CancellationToken cancellationToken = default) + => _inner.FlushAsync(cancellationToken); +} diff --git a/src/NexNet.Testing/Streaming/TestPipeFactory.cs b/src/NexNet.Testing/Streaming/TestPipeFactory.cs new file mode 100644 index 00000000..98504daf --- /dev/null +++ b/src/NexNet.Testing/Streaming/TestPipeFactory.cs @@ -0,0 +1,70 @@ +using System.Collections.Concurrent; +using System.Threading.Tasks; +using NexNet.Internals; +using NexNet.Pipes; +using NexNet.Testing.Quiescence; + +namespace NexNet.Testing.Streaming; + +/// +/// Harness implementation of . Remote (incoming) pipes handed to user +/// code are wrapped with a and their +/// registered by id for later lookup; locally-rented pipes pass through unwrapped (see +/// for why). Either way each pipe's lifetime is bracketed with +/// OpenPipe/ClosePipe on the shared counters. +/// +internal sealed class TestPipeFactory : IPipeFactory +{ + private readonly QuiescenceCounters _counters; + private readonly QuiescenceTracker _tracker; + private readonly ConcurrentDictionary _byId = new(); + + public TestPipeFactory(QuiescenceCounters counters, QuiescenceTracker tracker) + { + _counters = counters; + _tracker = tracker; + } + + public IRentedNexusDuplexPipe WrapLocal(IRentedNexusDuplexPipe inner) + { + // We deliberately do NOT wrap the locally-rented pipe: the framework's proxy invoker + // does an Unsafe.As(pipe) when reading the initial id (see + // ProxyInvocationBase.ProxyGetDuplexPipeInitialId), so a wrapper that merely implements + // INexusDuplexPipe would read garbage memory through the cast. Locally-rented pipes + // therefore use the byte-level transport tap (via BytesInTransit) for quiescence and + // give up the per-pipe-recording observation API on the caller side. Remote (incoming) + // pipes are still wrapped — see WrapRemote. The id-keying for `GetRecordingFor` is + // intentionally only populated by WrapRemote (where the id is known and stable at + // wrap-time); locally-rented pipes start at Id=0 and aren't lookup-able by id. + Track(inner.CompleteTask, recording: null); + return inner; + } + + public INexusDuplexPipe WrapRemote(INexusDuplexPipe inner) + { + var wrapped = new TappedNexusDuplexPipe(inner); + _byId.TryAdd(inner.Id, wrapped.Recording); + Track(inner.CompleteTask, wrapped.Recording); + return wrapped; + } + + private void Track(Task completeTask, PipeRecording? recording) + { + _counters.OpenPipe(); + _tracker.SignalChange(); + _ = completeTask.ContinueWith(t => + { + // Drive both the quiescence bookkeeping and the recording's completion signal from + // the single continuation. Previously the wrapper registered its own + // RecordCompletion continuation in parallel; consolidating here removes the duplicate + // callback path and the constructor-time race window it implied. + recording?.RecordCompletion(t.Exception?.GetBaseException()); + _counters.ClosePipe(); + _tracker.SignalChange(); + }, TaskScheduler.Default); + } + + /// Looks up the recording for a remote-registered pipe by id, if any. + public PipeRecording? GetRecordingFor(ushort pipeId) + => _byId.TryGetValue(pipeId, out var rec) ? rec : null; +} diff --git a/src/NexNet.Testing/Transports/InProcess/CountingPipeReader.cs b/src/NexNet.Testing/Transports/InProcess/CountingPipeReader.cs new file mode 100644 index 00000000..b8af54ae --- /dev/null +++ b/src/NexNet.Testing/Transports/InProcess/CountingPipeReader.cs @@ -0,0 +1,89 @@ +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Testing.Quiescence; + +namespace NexNet.Testing.Transports.InProcess; + +/// +/// wrapper that decrements +/// each time the consumer advances past committed bytes, matching the increment performed by the +/// paired . The delta is computed from the current +/// start to the supplied consumed position. +/// +internal sealed class CountingPipeReader : PipeReader +{ + private readonly PipeReader _inner; + private readonly QuiescenceCounters _counters; + private readonly QuiescenceTracker _tracker; + private ReadOnlySequence _lastBuffer; + private bool _hasLastBuffer; + + public CountingPipeReader(PipeReader inner, QuiescenceCounters counters, QuiescenceTracker tracker) + { + _inner = inner; + _counters = counters; + _tracker = tracker; + } + + public override void AdvanceTo(SequencePosition consumed) + { + RecordConsumed(consumed); + _inner.AdvanceTo(consumed); + } + + public override void AdvanceTo(SequencePosition consumed, SequencePosition examined) + { + RecordConsumed(consumed); + _inner.AdvanceTo(consumed, examined); + } + + public override void CancelPendingRead() => _inner.CancelPendingRead(); + + public override void Complete(Exception? exception = null) => _inner.Complete(exception); + + public override ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + var task = _inner.ReadAsync(cancellationToken); + return task.IsCompletedSuccessfully ? new ValueTask(StoreBuffer(task.Result)) : Wait(task); + } + + private async ValueTask Wait(ValueTask task) + { + var result = await task.ConfigureAwait(false); + return StoreBuffer(result); + } + + public override bool TryRead(out ReadResult result) + { + if (_inner.TryRead(out result)) + { + result = StoreBuffer(result); + return true; + } + return false; + } + + private ReadResult StoreBuffer(ReadResult result) + { + _lastBuffer = result.Buffer; + _hasLastBuffer = true; + return result; + } + + private void RecordConsumed(SequencePosition consumed) + { + if (!_hasLastBuffer) + return; + + var consumedLength = _lastBuffer.Slice(0, consumed).Length; + if (consumedLength > 0) + { + _counters.AddBytesInTransit(-(int)consumedLength); + _tracker.SignalChange(); + } + _hasLastBuffer = false; + } +} diff --git a/src/NexNet.Testing/Transports/InProcess/CountingPipeWriter.cs b/src/NexNet.Testing/Transports/InProcess/CountingPipeWriter.cs new file mode 100644 index 00000000..0c3c276e --- /dev/null +++ b/src/NexNet.Testing/Transports/InProcess/CountingPipeWriter.cs @@ -0,0 +1,47 @@ +using System; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Testing.Quiescence; + +namespace NexNet.Testing.Transports.InProcess; + +/// +/// wrapper that increments a +/// bytes-in-transit count every time the producer commits bytes. The matching +/// decrements as those bytes are consumed. +/// +internal sealed class CountingPipeWriter : PipeWriter +{ + private readonly PipeWriter _inner; + private readonly QuiescenceCounters _counters; + private readonly QuiescenceTracker _tracker; + + public CountingPipeWriter(PipeWriter inner, QuiescenceCounters counters, QuiescenceTracker tracker) + { + _inner = inner; + _counters = counters; + _tracker = tracker; + } + + public override void Advance(int bytes) + { + _inner.Advance(bytes); + if (bytes > 0) + { + _counters.AddBytesInTransit(bytes); + _tracker.SignalChange(); + } + } + + public override Memory GetMemory(int sizeHint = 0) => _inner.GetMemory(sizeHint); + + public override Span GetSpan(int sizeHint = 0) => _inner.GetSpan(sizeHint); + + public override void CancelPendingFlush() => _inner.CancelPendingFlush(); + + public override void Complete(Exception? exception = null) => _inner.Complete(exception); + + public override ValueTask FlushAsync(CancellationToken cancellationToken = default) + => _inner.FlushAsync(cancellationToken); +} diff --git a/src/NexNet.Testing/Transports/InProcess/InProcessClientConfig.cs b/src/NexNet.Testing/Transports/InProcess/InProcessClientConfig.cs new file mode 100644 index 00000000..9c532182 --- /dev/null +++ b/src/NexNet.Testing/Transports/InProcess/InProcessClientConfig.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using NexNet.Transports; + +namespace NexNet.Testing.Transports.InProcess; + +/// +/// Client-side configuration for the in-process transport. The endpoint must match the +/// of a server that is already started in the +/// same process. +/// +public sealed class InProcessClientConfig : ClientConfig +{ + /// + /// Rendezvous key identifying which in-process server to connect to. + /// + public required string Endpoint { get; init; } + + /// + protected override ValueTask OnConnectTransport(CancellationToken cancellationToken) + { + var listener = InProcessRendezvous.Find(Endpoint) + ?? throw new TransportException( + TransportError.ConnectionRefused, + $"No InProcess listener is registered at endpoint '{Endpoint}'. " + + "The server must be started before clients can connect.", + null); + + return new ValueTask(listener.ConnectAsClient()); + } +} diff --git a/src/NexNet.Testing/Transports/InProcess/InProcessRendezvous.cs b/src/NexNet.Testing/Transports/InProcess/InProcessRendezvous.cs new file mode 100644 index 00000000..0a2fd3bf --- /dev/null +++ b/src/NexNet.Testing/Transports/InProcess/InProcessRendezvous.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Concurrent; + +namespace NexNet.Testing.Transports.InProcess; + +/// +/// Process-local registry mapping endpoint names to +/// instances. Servers register themselves on listener creation; clients look up the registered +/// listener by endpoint to obtain a paired transport. +/// +/// +/// +/// The registry is a static field, so its scope is one +/// (equivalently: one graph that +/// resolves this assembly). Endpoint names are arbitrary strings; tests typically use a unique +/// GUID-derived value per test method so concurrent runs in the same AppDomain do not collide. +/// generates such a GUID-suffixed endpoint +/// automatically. +/// +/// +/// Multiple listeners cannot register under the same endpoint; the second attempt throws. +/// Multiple test assemblies loaded into separate AssemblyLoadContexts (e.g. some hot-reload +/// scenarios) each get their own registry instance and cannot see each other's listeners; this +/// is normally what tests want, but worth noting if a future runner splits a single logical +/// test into multiple ALCs. +/// +/// +internal static class InProcessRendezvous +{ + private static readonly ConcurrentDictionary _listeners = new(); + + /// + /// Registers a listener under the given endpoint name. Throws if the endpoint is already taken. + /// + public static void Register(string endpoint, InProcessTransportListener listener) + { + if (!_listeners.TryAdd(endpoint, listener)) + throw new InvalidOperationException( + $"An InProcess listener is already registered at endpoint '{endpoint}'."); + } + + /// + /// Removes the listener registration for the given endpoint, if any. Safe to call on an + /// already-unregistered endpoint. Removal is conditional: we only remove the entry when it + /// still maps to , so a different listener that re-registered + /// under the same endpoint between Register and Unregister isn't blown away. + /// + public static void Unregister(string endpoint, InProcessTransportListener listener) + { + if (_listeners.TryGetValue(endpoint, out var current) && ReferenceEquals(current, listener)) + _listeners.TryRemove(endpoint, out _); + } + + /// + /// Looks up the listener registered under the given endpoint name. Returns null when no + /// listener is registered. + /// + public static InProcessTransportListener? Find(string endpoint) + { + return _listeners.TryGetValue(endpoint, out var listener) ? listener : null; + } +} diff --git a/src/NexNet.Testing/Transports/InProcess/InProcessServerConfig.cs b/src/NexNet.Testing/Transports/InProcess/InProcessServerConfig.cs new file mode 100644 index 00000000..78796d54 --- /dev/null +++ b/src/NexNet.Testing/Transports/InProcess/InProcessServerConfig.cs @@ -0,0 +1,48 @@ +using System.Threading; +using System.Threading.Tasks; +using NexNet.Testing.Quiescence; +using NexNet.Transports; + +namespace NexNet.Testing.Transports.InProcess; + +/// +/// Server-side configuration for the in-process transport. The endpoint name is the rendezvous +/// key clients use to find this server; tests typically supply a unique value per test method. +/// +public sealed class InProcessServerConfig : ServerConfig +{ + /// + /// Rendezvous key used to locate this server from a matching . + /// Must be unique per concurrently-running server within the process. + /// + public required string Endpoint { get; init; } + + /// + /// Optional quiescence counters installed by the test harness. When set together with + /// , every transport produced by the listener feeds + /// on each side of the connection. + /// + internal QuiescenceCounters? Counters { get; set; } + + /// + /// Tracker that owns the counters; receives a SignalChange on every byte movement so + /// awaiters of quiescence are reliably woken. + /// + internal QuiescenceTracker? Tracker { get; set; } + + /// + /// Creates a new in-process server configuration. + /// + public InProcessServerConfig() + : base(ServerConnectionMode.Listener) + { + } + + /// + protected override ValueTask OnCreateServerListener(CancellationToken cancellationToken) + { + var listener = new InProcessTransportListener(Endpoint, Counters, Tracker); + InProcessRendezvous.Register(Endpoint, listener); + return new ValueTask(listener); + } +} diff --git a/src/NexNet.Testing/Transports/InProcess/InProcessTransport.cs b/src/NexNet.Testing/Transports/InProcess/InProcessTransport.cs new file mode 100644 index 00000000..899c3734 --- /dev/null +++ b/src/NexNet.Testing/Transports/InProcess/InProcessTransport.cs @@ -0,0 +1,59 @@ +using System.IO.Pipelines; +using System.Threading.Tasks; +using NexNet.Testing.Quiescence; +using NexNet.Transports; + +namespace NexNet.Testing.Transports.InProcess; + +/// +/// In-process implementation of that exposes a pair of +/// ends as the duplex stream. Used in pairs: the server +/// side and client side of a single connection share two pipes cross-wired so that one side's +/// output is the other side's input. When constructed with non-null quiescence inputs, the +/// reader/writer are wrapped to feed . +/// +internal sealed class InProcessTransport : ITransport +{ + private readonly PipeReader _input; + private readonly PipeWriter _output; + private bool _closed; + + public PipeReader Input => _input; + public PipeWriter Output => _output; + public string? RemoteAddress { get; } + public int? RemotePort => null; + + public InProcessTransport( + PipeReader input, + PipeWriter output, + string remoteAddress, + QuiescenceCounters? counters = null, + QuiescenceTracker? tracker = null) + { + if (counters is not null && tracker is not null) + { + _input = new CountingPipeReader(input, counters, tracker); + _output = new CountingPipeWriter(output, counters, tracker); + } + else + { + _input = input; + _output = output; + } + RemoteAddress = remoteAddress; + } + + public ValueTask CloseAsync(bool linger) + { + if (_closed) + return ValueTask.CompletedTask; + _closed = true; + + // Completing the writer signals to the peer's reader that no more data will arrive. + // Completing the reader signals to the peer's writer that no further reads will occur. + // The `linger` flag is a no-op for in-process: there is no socket buffer to drain. + try { _output.Complete(); } catch { /* already completed */ } + try { _input.Complete(); } catch { /* already completed */ } + return ValueTask.CompletedTask; + } +} diff --git a/src/NexNet.Testing/Transports/InProcess/InProcessTransportListener.cs b/src/NexNet.Testing/Transports/InProcess/InProcessTransportListener.cs new file mode 100644 index 00000000..cb864123 --- /dev/null +++ b/src/NexNet.Testing/Transports/InProcess/InProcessTransportListener.cs @@ -0,0 +1,110 @@ +using System; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using NexNet.Testing.Quiescence; +using NexNet.Transports; + +namespace NexNet.Testing.Transports.InProcess; + +/// +/// Listener that pairs incoming in-process client connections with the running server. +/// Producers (clients calling ) build a paired transport and +/// enqueue the server-side half; consumers (the NexNet server) dequeue via +/// . +/// +internal sealed class InProcessTransportListener : ITransportListener +{ + private readonly string _endpoint; + private readonly Channel _accepted; + private readonly QuiescenceCounters? _counters; + private readonly QuiescenceTracker? _tracker; + private int _connectionSequence; + private bool _closed; + + public InProcessTransportListener( + string endpoint, + QuiescenceCounters? counters = null, + QuiescenceTracker? tracker = null) + { + _endpoint = endpoint; + _counters = counters; + _tracker = tracker; + // Unbounded so clients never block on connect; the server's accept loop drains. + _accepted = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + } + + /// + /// Constructs a transport pair (server-side and client-side), enqueues the server-side for + /// the server's accept loop, and returns the client-side to the connecting client. + /// + public ITransport ConnectAsClient() + { + if (_closed) + throw new InvalidOperationException( + $"InProcess listener '{_endpoint}' is closed; cannot connect."); + + // Two pipes, cross-wired: clientToServer carries client writes -> server reads; + // serverToClient carries server writes -> client reads. + var clientToServer = new Pipe(PipeOptions.Default); + var serverToClient = new Pipe(PipeOptions.Default); + + var connectionId = Interlocked.Increment(ref _connectionSequence); + var serverAddress = $"inproc://{_endpoint}/server#{connectionId}"; + var clientAddress = $"inproc://{_endpoint}/client#{connectionId}"; + + var serverSide = new InProcessTransport( + input: clientToServer.Reader, + output: serverToClient.Writer, + remoteAddress: clientAddress, + counters: _counters, + tracker: _tracker); + + var clientSide = new InProcessTransport( + input: serverToClient.Reader, + output: clientToServer.Writer, + remoteAddress: serverAddress, + counters: _counters, + tracker: _tracker); + + if (!_accepted.Writer.TryWrite(serverSide)) + throw new InvalidOperationException( + $"Failed to enqueue server-side transport on listener '{_endpoint}'."); + + return clientSide; + } + + /// + public async ValueTask AcceptTransportAsync(CancellationToken cancellationToken) + { + try + { + return await _accepted.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + } + catch (ChannelClosedException) + { + return null; + } + catch (OperationCanceledException) + { + return null; + } + } + + /// + public ValueTask CloseAsync(bool linger) + { + if (_closed) + return ValueTask.CompletedTask; + _closed = true; + + _accepted.Writer.TryComplete(); + InProcessRendezvous.Unregister(_endpoint, this); + return ValueTask.CompletedTask; + } +} diff --git a/src/NexNet.slnx b/src/NexNet.slnx index 0b824e05..625179c8 100644 --- a/src/NexNet.slnx +++ b/src/NexNet.slnx @@ -20,6 +20,8 @@ + + \ No newline at end of file diff --git a/src/NexNet/Internals/IInvocationInterceptor.cs b/src/NexNet/Internals/IInvocationInterceptor.cs new file mode 100644 index 00000000..2a639e95 --- /dev/null +++ b/src/NexNet/Internals/IInvocationInterceptor.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using NexNet.Messages; + +namespace NexNet.Internals; + +/// +/// Optional internal hook that wraps the dispatch of an incoming invocation. When installed via +/// , the session calls +/// in place of invoking the nexus method directly, allowing the caller +/// (e.g., the test harness) to record the invocation and track in-flight dispatch counts for +/// quiescence purposes. +/// +/// +/// Outer-only interception: the implementation sees the raw +/// (method id + serialized argument bytes), not deserialized arguments. To preserve normal +/// dispatch semantics, the implementation MUST call the supplied invoke delegate exactly once +/// (typically inside a try/finally so counter state stays balanced if the invocation throws). +/// +internal interface IInvocationInterceptor +{ + /// + /// Wraps the dispatch of a single incoming invocation. Implementations must invoke + /// to run the underlying nexus method. + /// + /// The raw invocation message about to be dispatched. + /// Delegate that runs the underlying nexus method dispatch. + ValueTask WrapAsync(InvocationMessage message, Func invoke); +} diff --git a/src/NexNet/Internals/INexusSession.cs b/src/NexNet/Internals/INexusSession.cs index 0be5d2e6..1d25f565 100644 --- a/src/NexNet/Internals/INexusSession.cs +++ b/src/NexNet/Internals/INexusSession.cs @@ -65,6 +65,12 @@ internal interface INexusSession : ISessionMessenger /// ConfigBase Config { get; } + /// + /// Optional pipe factory snapshot captured at session construction. Null in production + /// paths; non-null when the test harness has installed a tap. + /// + IPipeFactory? PipeFactory { get; } + ConnectionState State { get; } bool IsServer { get; } NexusPipeManager PipeManager { get; } diff --git a/src/NexNet/Internals/IPipeFactory.cs b/src/NexNet/Internals/IPipeFactory.cs new file mode 100644 index 00000000..12fe02f5 --- /dev/null +++ b/src/NexNet/Internals/IPipeFactory.cs @@ -0,0 +1,34 @@ +using NexNet.Pipes; + +namespace NexNet.Internals; + +/// +/// Optional internal hook that wraps duplex pipes at the boundary where they are returned to +/// user code. When installed via , the +/// pipe manager passes pipes through this factory before handing them to a handler, allowing +/// the caller (e.g., the test harness) to interpose tap wrappers that record bytes the handler +/// reads/writes and to track the lifetime of in-flight pipes for quiescence purposes. +/// +/// +/// Wrappers are expected to compose the inner pipe (delegating all interface members) rather +/// than substitute for it: the pipe manager's internal active-pipe registry continues to hold +/// the underlying concrete pipe so incoming-data routing is unaffected. Only the reference +/// surfaced to user code is the wrapper. +/// +internal interface IPipeFactory +{ + /// + /// Wraps a pipe that the local side just rented via . + /// + /// The freshly-rented pipe. + /// A wrapper to expose to user code, or unchanged. + IRentedNexusDuplexPipe WrapLocal(IRentedNexusDuplexPipe inner); + + /// + /// Wraps a pipe that the local side just registered in response to a remote pipe request + /// (see ). + /// + /// The pipe just made ready for the responder handler. + /// A wrapper to expose to user code, or unchanged. + INexusDuplexPipe WrapRemote(INexusDuplexPipe inner); +} diff --git a/src/NexNet/Internals/NexusSession.Receiving.cs b/src/NexNet/Internals/NexusSession.Receiving.cs index 90781839..6495e28b 100644 --- a/src/NexNet/Internals/NexusSession.Receiving.cs +++ b/src/NexNet/Internals/NexusSession.Receiving.cs @@ -691,7 +691,16 @@ static async void InvocationTask(object? argumentsObj) try { arguments.Session.Logger?.LogTrace($"Invoking method {message.MethodId}."); - await session._nexus.InvokeMethod(message).ConfigureAwait(false); + var interceptor = session._invocationInterceptor; + if (interceptor is null) + { + await session._nexus.InvokeMethod(message).ConfigureAwait(false); + } + else + { + await interceptor.WrapAsync(message, + () => session._nexus.InvokeMethod(message)).ConfigureAwait(false); + } } catch (Exception e) { diff --git a/src/NexNet/Internals/NexusSession.cs b/src/NexNet/Internals/NexusSession.cs index fcd86fdb..89782c24 100644 --- a/src/NexNet/Internals/NexusSession.cs +++ b/src/NexNet/Internals/NexusSession.cs @@ -80,6 +80,13 @@ private record struct ProcessResult( private readonly string? _rateLimiterAddress; private readonly IConnectionRateLimiter? _rateLimiter; + // Optional invocation interceptor copied from the session configurations; null on the + // production hot path so dispatch is a direct call to the nexus. + internal readonly IInvocationInterceptor? _invocationInterceptor; + + // Optional pipe factory copied from the session configurations; null in production paths. + internal readonly IPipeFactory? _pipeFactory; + /// /// State of the connection that /// @@ -142,6 +149,7 @@ public ConnectionState State } public ConfigBase Config { get; } + public IPipeFactory? PipeFactory => _pipeFactory; public bool IsServer { get; } public DisconnectReason DisconnectReason { get; private set; } = DisconnectReason.None; @@ -176,6 +184,9 @@ public NexusSession(in NexusSessionConfigurations configurations _rateLimiterAddress = configurations.RateLimiterAddress; _rateLimiter = configurations.RateLimiter; + _invocationInterceptor = configurations.InvocationInterceptor; + _pipeFactory = configurations.PipeFactory; + Logger = configurations.Logger?.CreateLogger($"S{Id}"); if(configurations.ConnectionState == ConnectionState.Reconnecting) diff --git a/src/NexNet/Internals/NexusSessionConfigurations.cs b/src/NexNet/Internals/NexusSessionConfigurations.cs index 0de9ef3d..e17b192b 100644 --- a/src/NexNet/Internals/NexusSessionConfigurations.cs +++ b/src/NexNet/Internals/NexusSessionConfigurations.cs @@ -42,4 +42,18 @@ internal readonly struct NexusSessionConfigurations /// Rate limiter reference for release on disconnect. /// public IConnectionRateLimiter? RateLimiter { get; init; } + + /// + /// Optional invocation interceptor copied from + /// at the construction site. Read directly by the session's invocation dispatcher; null in + /// production paths. + /// + public IInvocationInterceptor? InvocationInterceptor { get; init; } + + /// + /// Optional pipe factory copied from at the + /// construction site. Consulted by the session's pipe manager when wrapping pipes for + /// user code; null in production paths. + /// + public IPipeFactory? PipeFactory { get; init; } } diff --git a/src/NexNet/Invocation/ISessionInvocationStateManager.cs b/src/NexNet/Invocation/ISessionInvocationStateManager.cs index bdaf907b..d9ce4427 100644 --- a/src/NexNet/Invocation/ISessionInvocationStateManager.cs +++ b/src/NexNet/Invocation/ISessionInvocationStateManager.cs @@ -45,4 +45,13 @@ internal interface ISessionInvocationStateManager /// Cancels all pending invocations. /// void CancelAll(); + + /// + /// Gets the count of invocations that have been sent and are still awaiting a result. + /// + /// + /// Reads the live count from the concurrent registry of in-flight invocations. + /// Intended for diagnostic / test-harness use (e.g., quiescence tracking); not part of the public surface. + /// + int PendingInvocationCount { get; } } diff --git a/src/NexNet/Invocation/ServerNexusBase.cs b/src/NexNet/Invocation/ServerNexusBase.cs index 941e7ae6..df047e2e 100644 --- a/src/NexNet/Invocation/ServerNexusBase.cs +++ b/src/NexNet/Invocation/ServerNexusBase.cs @@ -25,6 +25,11 @@ public abstract class ServerNexusBase : NexusBase internal ValueTask Authenticate(ReadOnlyMemory? authenticationToken) { + if (SessionContext.Session.Config is ServerConfig serverConfig && + serverConfig.OnAuthenticateOverride is { } authenticateOverride) + { + return authenticateOverride(authenticationToken); + } return OnAuthenticate(authenticationToken); } diff --git a/src/NexNet/Invocation/SessionInvocationStateManager.cs b/src/NexNet/Invocation/SessionInvocationStateManager.cs index b52d5dd7..4ea6ca48 100644 --- a/src/NexNet/Invocation/SessionInvocationStateManager.cs +++ b/src/NexNet/Invocation/SessionInvocationStateManager.cs @@ -173,4 +173,6 @@ public void CancelAll() //foreach (var invocationState in _waitingPipes) // invocationState.Value.Pipe.UpstreamComplete(PipeCompleteMessage.Flags.Canceled); } + + public int PendingInvocationCount => _invocationStates.Count; } diff --git a/src/NexNet/NexNet.csproj b/src/NexNet/NexNet.csproj index 186abc94..ace2fda8 100644 --- a/src/NexNet/NexNet.csproj +++ b/src/NexNet/NexNet.csproj @@ -8,6 +8,7 @@ + diff --git a/src/NexNet/NexusClient.cs b/src/NexNet/NexusClient.cs index c6e7b9ed..3a664e01 100644 --- a/src/NexNet/NexusClient.cs +++ b/src/NexNet/NexusClient.cs @@ -149,7 +149,7 @@ private async Task TryConnectAsyncCore(bool isReconnecting, Ca var config = new NexusSessionConfigurations() { - ConnectionState = isReconnecting ? ConnectionState.Reconnecting : ConnectionState.Connecting, + ConnectionState = isReconnecting ? ConnectionState.Reconnecting : ConnectionState.Connecting, Configs = _config, Transport = transport, Pool = _poolManager, @@ -160,7 +160,9 @@ private async Task TryConnectAsyncCore(bool isReconnecting, Ca ReadyTaskCompletionSource = readyTaskCompletionSource, DisconnectedTaskCompletionSource = disconnectedTaskCompletionSource, CollectionManager = _collectionManager, - Logger = _logger + Logger = _logger, + InvocationInterceptor = _config.InvocationInterceptor, + PipeFactory = _config.PipeFactory }; var session = _session = new NexusSession(config) diff --git a/src/NexNet/NexusServer.cs b/src/NexNet/NexusServer.cs index 584c84f2..6fd49d55 100644 --- a/src/NexNet/NexusServer.cs +++ b/src/NexNet/NexusServer.cs @@ -33,6 +33,14 @@ public sealed class NexusServer : INexusServer Interlocked.Exchange(ref _idCounter, 0); private IServerSessionManager _sessionManager = null!; + + /// + /// Live session/group registry. Exposed to in-assembly callers (and the test harness via + /// InternalsVisibleTo) so test fixtures can inspect membership without going through + /// the public proxy surface. + /// + internal IServerSessionManager SessionManagerInternal => _sessionManager; + private ITimer? _watchdogTimer; private ServerConfig? _config; private Func? _nexusFactory; @@ -322,7 +330,9 @@ ValueTask IAcceptsExternalTransport.AcceptTransport(ITransport transport, Cancel CollectionManager = _collectionManager, Logger = _logger, RateLimiterAddress = remoteAddress, - RateLimiter = _rateLimiter + RateLimiter = _rateLimiter, + InvocationInterceptor = _config.InvocationInterceptor, + PipeFactory = _config.PipeFactory }, cancellationToken); } @@ -460,7 +470,9 @@ private async Task ListenForConnectionsAsync() CollectionManager = _collectionManager, Logger = _logger, RateLimiterAddress = remoteAddress, - RateLimiter = _rateLimiter + RateLimiter = _rateLimiter, + InvocationInterceptor = _config.InvocationInterceptor, + PipeFactory = _config.PipeFactory }); } } diff --git a/src/NexNet/Pipes/NexusPipeManager.cs b/src/NexNet/Pipes/NexusPipeManager.cs index 259c84b4..382d915f 100644 --- a/src/NexNet/Pipes/NexusPipeManager.cs +++ b/src/NexNet/Pipes/NexusPipeManager.cs @@ -48,7 +48,8 @@ public void Setup(INexusSession session) _activePipes.TryAdd(partialId, pipe); - return pipe; + var factory = _session.PipeFactory; + return factory is null ? pipe : factory.WrapLocal(pipe); } /// @@ -96,7 +97,9 @@ public async ValueTask RegisterPipe(byte otherId) pipe.UpdateState(State.Ready); await pipe.NotifyState().ConfigureAwait(false); _logger?.LogTrace($"Sending Ready Notification"); - return pipe; + + var factory = _session.PipeFactory; + return factory is null ? pipe : factory.WrapRemote(pipe); } public async ValueTask DeregisterPipe(INexusDuplexPipe pipe) diff --git a/src/NexNet/Transports/ConfigBase.cs b/src/NexNet/Transports/ConfigBase.cs index c7a8b0de..5e7458ea 100644 --- a/src/NexNet/Transports/ConfigBase.cs +++ b/src/NexNet/Transports/ConfigBase.cs @@ -192,4 +192,18 @@ public int NexusPipeHighWaterCutoff internal Action? InternalOnSessionSetup; internal bool InternalNoLingerOnShutdown = false; internal bool InternalForceDisableSendingDisconnectSignal = false; + + /// + /// Optional hook that wraps the dispatch of incoming invocations on every session created + /// from this config. Used by the test harness for invocation recording and quiescence tracking. + /// When null, sessions invoke the nexus directly (the default production path). + /// + internal IInvocationInterceptor? InvocationInterceptor { get; set; } + + /// + /// Optional factory that wraps duplex pipes at the boundary between the pipe manager and + /// user code. Used by the test harness to install tap wrappers for byte-level recording + /// and pipe-lifetime tracking. When null, pipes are returned to user code unwrapped. + /// + internal IPipeFactory? PipeFactory { get; set; } } diff --git a/src/NexNet/Transports/ServerConfig.cs b/src/NexNet/Transports/ServerConfig.cs index 8a12f4d2..eb68e0d8 100644 --- a/src/NexNet/Transports/ServerConfig.cs +++ b/src/NexNet/Transports/ServerConfig.cs @@ -42,6 +42,14 @@ public abstract class ServerConfig : ConfigBase /// public TimeSpan? AuthorizationCacheDuration { get; set; } + /// + /// Optional override for client authentication. When set, the server consults this + /// delegate instead of the user nexus's OnAuthenticate override. Used by the + /// test harness to install fake-identity mapping without forcing users to disable or + /// replace their real auth code. Leave null in production paths. + /// + internal Func?, ValueTask>? OnAuthenticateOverride { get; set; } + /// /// Creates the listener and starts. ///