Files
UA-.NETStandard/tools
romanettandClaude Opus 5.5 48871a7e87 Client: completed requests no longer keep their responses alive (#4870)
## Problem
A client request that lives across a GC is promoted. Publish requests
always are: they wait at the server for the publishing interval. When
the response arrives, two **dead** old objects still reference it:

1. `UaSCUaBinaryChannel.WriteOperation.MessageBody`:
`UaSCUaBinaryClientChannel.SendRequestAsync` read it but never cleared
it.
2. The box of the generated `SessionClient.*Async` method. It is `async
ValueTask<T>` with the default builder, so a call that completes
asynchronously leaves a `Task<T>` box holding the result and the hoisted
`response` locals.

The GC treats old objects as live. Every gen0 and gen1 collection
therefore keeps the whole response alive through the card table until a
full collection. With 100k monitored items, every `PublishResponse` (100
`MonitoredItemNotification`s) ended up promoted to gen2.

Evidence (ClrMD scan of a live client dump, 25 sessions × 40
subscriptions × 100 items): 1,073 KB of gen0 was kept only by dead old
objects, against 136 KB kept by live ones. The dead holders were exactly
the two above, holding the same responses. Gen2 held 37 MB of dead
notifications.

## Change
- `SendRequestAsync` clears `operation.MessageBody` once it has read the
response.
- `ClientApiTemplates`: generated `SessionClient` async methods use
`PoolingAsyncValueTaskMethodBuilder` on .NET 6+. A completed box resets
its result and state machine. This is the same pattern #4849 applied to
the channel and `ManagedSession`.

Contract note: the generated `SessionClient` calls now return pooled
value tasks, so each must be awaited once, like any value task. All
sync-over-async call sites in the repo use `.AsTask()` first.

After the fix, young data kept only by dead old objects is 0 KB (was
1,073 KB).

## Measurements
`perf/ServerLoadHarness`, client `client-gc:` line. Master vs this
branch, 3 alternating rounds, runs gated on foreign CPU load (shared
machine). Ranges are over the rounds.

| scenario | build | pause share | GC p50 ms | survived/gen0 |
survived/gen1 | gen2 MB | B/op |
|---|---|---|---|---|---|---|---|
| sub 100k items (25×40×100, pub 500 ms) | master | 4.9–5.4 % | 7.9–8.2
| 2.28–2.57 MB | 1.4–1.7 MB | 121–123 | 235–238 |
| | this PR | 4.0–4.5 % | 6.1–6.9 | 0.64–0.71 MB | 0.19 MB | 86 |
233–240 |
| sub 50k items (25×20×100) | master | 1.7–2.2 % | 5.5–6.5 | 2.1–2.2 MB
| 0.8–1.1 MB | 56–62 | 233–238 |
| | this PR | 1.7–1.8 % | 5.2–5.5 | 0.39–0.41 MB | 0.1–0.2 MB | 44–49 |
232 |
| read 1 node, 200 sessions | master | 8.9–9.2 % | 2.2–2.4 | 200 KB | |
20 | 1456 |
| | this PR | 7.3–7.7 % | 2.2–2.7 | 113–163 KB | | 20 | 1272 |
| read 1 node, 20 sessions | master | 3.3–3.6 % | 0.9–1.2 | 50–53 KB | |
4.4 | 1456 |
| | this PR | 2.8–2.9 % | 0.85–1.16 | 32–46 KB | | 4.4 | 1272 |

The first fix round at 100k ran while another process starved the server
(46k notif/s); it is left out of the fix row.

- At 50k items, gen1 collections drop from 15 to 1 per 10 s.
- GC phases at 100k items (TraceAnalyzer `gc`): card marking 3.3 → 2.55
ms (2.8 → 0.72 MB marked), plan+relocate+compact 6.05 → 3.27 ms.
- Allocation: −184 B per request (−12.6 %), because the `Task` box per
call is gone.

## Tests
net10.0: Opc.Ua.Client.Tests 3283 passed / 0 failed, Opc.Ua.Core.Tests
6535/0, Opc.Ua.SourceGeneration.Core.Tests 4412/0, Opc.Ua.Sessions.Tests
857/0. The net48 Release build of Opc.Ua.Client (with Core) is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:14:42 +02:00
..