docs(driver-expansion): resolve all open design concerns from the review pass
v2-ci / build (push) Successful in 3m30s
v2-ci / unit-tests (push) Failing after 8m53s

Every concern the 7-agent review parked is now decided and integrated:

- universal browser: in-flight capture coalescing keyed (driverType,
  config-hash) + global cap of 4 concurrent captures; CanBrowse catches a
  throwing TryCreate and defensively shuts down the throwaway instance;
  cleanup ShutdownAsync bounded at 10s (R2-01); picker Refresh = close +
  re-capture. Coalesced sessions share an immutable tree, so DisposeAsync
  drops the session's reference, not the tree.
- mtconnect: UNAVAILABLE pinned to BadNoCommunication (fleet's
  BadCommunicationError stays reserved for the driver's own transport
  failures); TIME_SERIES sampleCount flows into ArrayDim; Subscribe timeout
  bounds only the stream-start handshake; library version/TFM folded into
  the license checklist.
- mqtt: browse rebirth is an explicit DriverOperator-gated "Request rebirth"
  button (RequestRebirthAsync(scope)) - never fired by open/root/expand;
  hand-rolled reconnect loop committed for P1 (MQTTnet v5 dropped
  ManagedMqttClient); Wave-2-start library re-verification checkbox;
  implement from the Sparkplug v3.0 spec text.
- bacnet: P1 opens with a first-spike checklist (package TFM, BBMD/
  segmented-RPM/COV API smoke, unicast-I-Am fixture behavior); AdminUI-node
  browse registers a second foreign device - BBMD FD-table sizing note.
- sql-poll: split-node browse needs env parity for Sql__ConnectionStrings__
  refs on admin nodes (actionable error + session-only pasted literal);
  one-row-per-key query contract (last-wins + rate-limited warning);
  absent key -> BadNoData; operationTimeout > commandTimeout authoring rule;
  Browser->Driver.Sql SqlClient transitive on AdminUI accepted on record.
- omron: FINS framer gated on W227 + live golden vectors; CIP string layout
  is the first P1 live-gate item; AbCip harness static-init anti-pattern
  note; writable-defaults-true operator warning.
- modbus-rtu: first P1 step confirms pymodbus exposes framer=rtu on a TCP
  server (custom-script fallback otherwise).
- program doc: new cross-cutting rule - driver ctors must be connection-free
  (the universal browser's CanBrowse throwaway instances depend on it).
This commit is contained in:
Joseph Doherty
2026-07-15 16:54:09 -04:00
parent cf03ca279d
commit fd0bec4ffe
8 changed files with 210 additions and 52 deletions
@@ -184,7 +184,18 @@ line in the program doc's cross-cutting rules when P1 lands).
Serves entirely from the in-memory captured tree (the shape the MTConnect research report
proposed for its cached-`/probe` session — now subsumed by this generic component): `RootAsync` = top-level nodes; `ExpandAsync(nodeId)` = the captured children of that
node; `AttributesAsync(nodeId)` = the leaf's `AttributeInfo` (+ any captured properties).
`Token`/`LastUsedUtc` per the interface; `DisposeAsync` drops the tree. No I/O after open.
`Token`/`LastUsedUtc` per the interface; `DisposeAsync` drops the **session's reference** to
the tree (the tree is immutable and may be shared by coalesced sessions, §4.2 — disposing one
session never affects another). No I/O after open.
**Point-in-time snapshot + Refresh.** The captured tree is a snapshot taken at open time and
never updates for the session's whole TTL — a tag added on the controller after open does not
appear. The picker therefore exposes a **Refresh** action = close the session + re-open it
(`CloseAsync` then `OpenAsync` with the same driverType/configJson): a fresh capture that flows
through the same coalescing/cap path as any open. Because the coalescing map holds in-flight
captures only (no result caching), Refresh always yields a new capture — or joins one already
in flight, which is equally fresh. Cheap to build (the picker already has both calls); the UI
should label browse results as "snapshot taken at open/refresh time".
## 5. The online-discovery capability gate
@@ -276,9 +287,10 @@ turns the FixedTree on; the settle waits for its cache to fill.)
1. **Eager, not lazy.** `DiscoverAsync` runs the whole enumeration at open. For huge trees
(ControlLogix 10 k+ tags, OPC UA 100 k nodes) the node-cap truncates and the UX is worse than
a bespoke per-click `ExpandAsync`. Graduate those to Tier 2.
2. **Runs the real driver in-process** (construct + connect + one-shot discover + shutdown) —
heavier than the lightweight ad-hoc bespoke session, and requires the driver assemblies in the
host (fused Host: yes).
2. **Runs the real driver in-process** (construct + connect + one-shot discover + bounded
shutdown) — heavier than the lightweight ad-hoc bespoke session, and requires the driver
assemblies in the host (fused Host: yes). The §4.2 coalescing + `MaxConcurrentCaptures` cap
bound the aggregate load, but each individual capture is still a full device walk.
3. **No incrementally-fetched attribute panel** beyond what `DriverAttributeInfo` +
`AddProperty` carry (fine for the picker's needs; Galaxy's richer two-stage pick stays bespoke).
@@ -288,10 +300,29 @@ turns the FixedTree on; the settle waits for its cache to fill.)
known Folder/Variable/AddProperty/alarm graph; assert the `BrowseNode` tree, leaf `NodeId ==
FullName`, `AttributeInfo` mapping, node-cap truncation, and the no-op alarm sink.
- **Unit — `DiscoveryDriverBrowser`:** fake `IDriverFactory` returning a fake driver; assert
Initialize→Discover→Shutdown ordering, open-timeout, `PatchForBrowse` merge, `CanBrowse` gate
Initialize→Discover→Shutdown ordering (shutdown always runs, even on discovery failure),
open-timeout, `PatchForBrowse` merge, `CanBrowse` gate
(true for `SupportsOnlineDiscovery`, false otherwise / `TryCreate`→null), and the
`UntilStable` settle (a fake `UntilStable` driver whose tree only fills on the second
discovery pass must yield the full tree; a never-stable one must stop at the open-timeout).
- **Unit — capture coalescing:** two concurrent `OpenAsync` calls with the same
(driverType, configJson) run **one** capture (fake driver counts Initialize/Discover
invocations) and both callers receive **distinct session tokens** over the shared tree;
disposing one session leaves the other browsable; a failed capture fails both awaiters; a
subsequent open after completion (Refresh path) runs a **new** capture (in-flight-only map,
no result caching).
- **Unit — capture cap queueing:** with `MaxConcurrentCaptures = 4` and >4 concurrent opens on
**distinct** keys (fake drivers gated on a test signal), at most 4 captures are in flight at
once; queued opens complete after a slot frees; a queued open observes caller cancellation.
- **Unit — `CanBrowse` hardening:** a fake factory whose `TryCreate` **throws** on malformed
configJson yields `CanBrowse == false` (no exception escapes); when `TryCreate` returns a
non-discovery (or flag-false) instance, `CanBrowse` is false **and** the created instance
was shut down/disposed.
- **Unit — bounded shutdown:** a fake driver whose `ShutdownAsync` never completes — the open
still returns within open-timeout + the 10 s shutdown bound, on the **discovery** outcome,
for both legs: successful capture ⇒ a usable session is returned; failed discovery ⇒ the
discovery error (not a shutdown timeout) surfaces. The wedged instance is abandoned, not
awaited.
- **Unit — `BrowserSessionService` resolution:** bespoke-first, universal-fallback, and
manual-entry-when-neither; confirm no `ToDictionary` collision.
- **Integration (fixture-gated):** point the universal browser at the existing driver fixtures on
@@ -306,9 +337,12 @@ turns the FixedTree on; the settle waits for its cache to fill.)
- **P1 — universal browser end-to-end:** `CapturingAddressSpaceBuilder` +
`CapturedTreeBrowseSession` + `DiscoveryDriverBrowser` + `IUniversalDriverBrowser` +
`BrowserSessionService` fallback + `ITagDiscovery.SupportsOnlineDiscovery` default member +
the AbCip/TwinCAT/FOCAS patches + the `UntilStable` settle loop (§4.2) + DI line + AdminUI
`CanBrowse`→Browse-button gate. Set `SupportsOnlineDiscovery=true` on
OpcUaClient(n/a—bespoke), AbCip, TwinCAT, FOCAS. **~35 days.**
the AbCip/TwinCAT/FOCAS patches + the `UntilStable` settle loop (§4.2) + capture coalescing
and the `MaxConcurrentCaptures` cap + the bounded (10 s) shutdown + hardened `CanBrowse`
(catch-throwing-`TryCreate`, defensive teardown) + DI line + AdminUI
`CanBrowse`→Browse-button gate + the picker **Refresh** action (§4.3). Set
`SupportsOnlineDiscovery=true` on OpcUaClient(n/a—bespoke), AbCip, TwinCAT, FOCAS.
**~35 days.**
- **P2 — light up new drivers for free:** as MTConnect/BACnet land, each sets
`SupportsOnlineDiscovery=true` (+ patch if config-gated) and gets a working picker with no
browser code. (MQTT/Sparkplug and SQL poll ship bespoke browsers instead — program doc §4.)