Files
romanett ad6430f560 Fix transient PublishWorkerCount overshoot in the publish controller (#4344)
Fixes #4339.

## Cause

`PublishControllerAsync` sized the worker pool at the **top** of its
loop, before its first `await`. The `SubscriptionManager` constructor
calls the controller directly, so that first resize ran *synchronously
inside the constructor* — with
`MinPublishWorkerCount`/`MaxPublishWorkerCount` still at their defaults
(2 / 15), because owners configure them afterwards.

A session that already reports subscriptions — a classic, session-owned
subscription counts through
`ISubscriptionManagerContext.SessionSubscriptionCount` — therefore had
`1` clamped **up** to the default minimum of 2, spun up two workers in
the constructor, and published that overshoot through
`PublishWorkerCount`. The configured `Min = Max = 1` then shrank the
pool back asynchronously.

Confirmed with a probe before changing anything:

```
PROBE right after ctor: 2
```

which reproduces the issue's `Worker #0 STARTED / #0 PAUSED / #1
STARTED` log exactly.
`ClassicSessionSubscriptionStartsPublishWorkerAsync` pins the pool to
one worker and samples the property directly, so it failed whenever it
read it before the controller had shrunk the pool back.

## Fix

The control loop now waits first and sizes the pool afterwards:

```
wait -> reap exited workers -> resize -> publish the count
```

The pool is only ever sized in response to a signal or a worker exit,
never from unconfigured defaults, and the count is assigned once the
pool has settled — so a worker that has exited is never counted
alongside its replacement (the second half of the issue's *Expected
Behavior*).

Two supporting changes were required:

- **Publish workers now start on the thread pool.** The controller
creates them inline, and the worker loop only yields where it awaits
something not already complete. With the resize moved behind the wait,
workers are typically created while publishing is already running, which
made that starvation reachable: I hit it while testing — 430k publishes
in 2 s with `PublishControlCycles` stuck at 1 and `PublishWorkerCount`
stuck at 0. The same starvation would hang `DisposeAsync`, which awaits
the controller.
- **`Resume()`, and leaving publishing quiescence, now signal the
publish control**, so the pool is sized promptly once publishing starts
rather than waiting for an unrelated event.

## Tests

- `ClassicSessionSubscriptionStartsPublishWorkerAsync` now settles the
pool through the existing `WaitForPublishWorkerCountAsync` helper
instead of sampling the property bare.
- New
`PublishWorkerPoolIsNeverSizedFromDefaultsBeforeConfigurationAsync` pins
the constructor behaviour (count stays 0 while the defaults are in
force) and asserts the count never leaves 1 while workers publish.

## Verification

- `Opc.Ua.Client.Tests`: 2151/2151 pass.
- `Opc.Ua.Subscriptions.Tests`: 619 passed, 6 skipped, 0 failed (22 m 18
s).
- The two affected tests stressed 15x in a row: green.
- `dotnet format whitespace --verify-no-changes` clean.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:40:48 +00:00
..