Skip to content

v0.30.1

Choose a tag to compare

@github-actions github-actions released this 12 Oct 08:33
· 55 commits to main since this release
379827c

What changed and why

Fixes only — no public API was added, so this is a patch. Most of them make the cameras keep promises ICam already states.
Five sections change something a caller can observe; the three marked behavior change can break a caller that relied on the
old behavior, and each says what to do. 43 of the 50 new tests fail on 0.30.0; the other 7 are controls and guards that pass
before and after.

Take this release if you start live on a VirtualCam or VideoCaptureCam while a single grab may still be running, bind
commands with a CanExecute to CvDispCtrl's camera buttons, subscribe to GevCam or VideoCaptureCam notifications directly,
or call StartContinuous / StopContinuous on a ReconnectingCam from more than one thread.

Imaging — VirtualCam and VideoCaptureCam refuse a start or a second grab while a single grab is waiting (behavior change)

ICam says that while GrabOne waits for its frame, a second GrabOne and StartContinuous on the same camera throw
InvalidOperationException. GevCam did; VirtualCam and VideoCaptureCam did not track a pending grab, so both went ahead —
live frames or another grab's frame could reach the subscription waiting for this grab's answer, with no error.

  • Now both throw, with a message that says what to wait for. A handler that calls GrabOne or StartContinuous on the same camera
    from inside the grab's own FrameAcquired throws too (the camera logs it and the outer grab returns normally).
  • Who notices: a caller that starts live from another thread before a grab has returned. GrabFrameAsync with a short timeout
    returns null while GrabOne keeps running in the background — on VirtualCam up to about 1 s more right after a stop — and a
    start in that window now throws. On the same instance, Close then Open does not clear the mark while the previous session's
    grab is still inside its FrameAcquired handler.
  • What to do: start live after the grab has returned, or catch InvalidOperationException around StartContinuous where a
    grab may be in flight (as GevCam callers already had to).
  • Wrapped in ReconnectingCam, the refusal is rethrown to the caller and the live intent is rolled back, as it already was for GevCam.
  • The Demo sample's Live button reports the refusal in the status bar instead of closing the window.
  • Measured with gates that hold the grab in each window (image load, publish, the wait for a stopped live's last tick): 12 of the
    13 new tests failed before the fix, in five runs. VideoCaptureCam was measured with a generated MJPG clip. Not verified on Linux
    CI: if OpenCV there cannot write MJPG, those cases return early and show as passed.

Imaging — VideoCaptureCam reports a live loop that ended on its own

When the live loop died on an unexpected error, it logged one Error and left IsGrabbing true with no notification. The next
StartContinuous returned silently as "already running", and a wrapping ReconnectingCam never rebuilt the session. An
ObjectDisposedException that no stop caused was swallowed without a trace.

  • Now IsGrabbing drops to false and GrabbingChanged(false) is raised once, from the live thread ({Name}.Live), with a
    Warning; such an ObjectDisposedException is logged as an Error. StartContinuous restarts; ReconnectingCam rebuilds the
    session and resumes live.
  • Live resumes only if the failure was transient. A source whose every frame fails conversion (for example a pixel format
    CamFrame does not support) now cycles under ReconnectingCam — reopen, fail on the first frame, rebuild at the first backoff
    step — and logs each round. Before, it stopped after one Error line and kept reporting live. Per-frame isolation is a candidate
    for a later release.
  • Measured by making the loop throw through reflection (an unexpected exception and a disposed capture): red before, green after,
    including the ReconnectingCam rebuild.

Imaging.Gev — GevCam notifications follow the transition-only rule

  • Close / Dispose raise GrabbingChanged(false) only if acquisition was running. Closing an idle camera no longer reports a
    stop, and stop-then-close reports one stop instead of two.
  • After a control loss, Close / Dispose no longer raise ConnectionChanged(false) a second time.
  • On a control loss, the disconnect is now raised at the loss even if a GrabbingChanged subscriber throws (0.30.0 skipped it and
    the following Close raised it). Both subscribers' exceptions are logged as Errors ("a GrabbingChanged subscriber threw." /
    "a ConnectionChanged subscriber threw.").
  • An ObjectDisposedException from the acquisition library — which documents it for a disposed device and for a request that
    reaches a closing control channel — is now taken like a control loss at the device-command sites:
    • StopContinuous no longer throws it after the receive pump has stopped, and the stop is still reported.
    • Close no longer aborts halfway through its teardown on it.
    • StartContinuous no longer throws it either: it returns and reports live, and the control loss that follows reports the stop and
      the disconnect.
    • In StopContinuous only, if a later device step throws anything else, the stop is still reported and the exception still
      propagates. Close still stops at an exception outside the caught set.
  • Reopening after a control loss reports GrabbingChanged(false) when the dead session it folds had a receive pump started after
    the loss. That pump can exist when the mode write never reached the device: a camera without an AcquisitionMode node, or one
    whose mode lock is judged from a cached register value while live was running at the loss — "cable pulled during live, then the
    live button" (reproduced with a fake node map modeled on that lock, not on a camera).
  • Through ReconnectingCam: it unsubscribes before it closes, disposes or replaces an inner camera, and opens a new instance for
    every session, so the Close / Dispose / reopen changes are not visible through it. The ObjectDisposedException change is
    visible: a wrapped StopContinuous no longer rethrows it, and a wrapped StartContinuous that hit it no longer rolls back the
    live intent (read from the code).
  • How it was measured: a fake session set up through reflection and a node map whose port throws a chosen exception. The device
    teardown itself needs hardware and was not exercised. That the acquisition library raises these exceptions in the field is taken
    from its documented error contract and the decompiled 0.5.0 package, not observed on site.
  • Not changed, documented at the site (read only, unconfirmed): a frame from a timed-out single grab may become the first frame of
    the next live, because starting live does not drain or filter by start time. In free-running preview that is one stale frame; in
    triggered acquisition it could be the first target's answer and, for a consumer that pairs frames with targets in order, shift
    every pairing after it.

Imaging — ReconnectingCam with overlapping starts and stops (behavior change)

  • IsGrabbing no longer stays true over a stopped inner camera when an overlapping StopContinuous is still finishing and a later
    StartContinuous was stood down by the inner camera.
  • A late GrabbingChanged(true) from the inner camera is checked against the inner camera's current state.
  • If the inner camera's StopContinuous throws, the display is still settled: lowered if the inner camera had stopped, left on if
    it threw before stopping. The same exception is rethrown.
  • What a caller sees: IsGrabbing follows what the inner camera is actually doing. In an overlap window it can read false where it
    used to read a stale true. One extra false→true edge may appear when a deferred relight lands. A toggle that reads IsGrabbing
    in that window now routes to Start instead of Stop.
  • Notifications from different threads can reach a subscriber out of order. Under forced preemption a subscriber can see
    [true, true, false] while IsGrabbing is true. What to do: treat each notification as a cue and re-read IsGrabbing; do
    not mirror the notification's value.
  • Known limits, documented on the class and reproduced only with a fake camera (not observed on site):
    • In six places a read of the inner camera's state, taken outside the wrapper's lock, can set IsGrabbing true. A stop, a
      crossing start and a refused relight that all complete inside that gap can leave IsGrabbing true over a stopped inner camera
      until the next command or reconnect. A "live could not be restarted" warning preceded it in every reproduced ordering.
    • One opposite place: after a throwing stop, the same gap can leave IsGrabbing false over a running inner camera. This needs a
      camera whose StopContinuous can throw before turning live off, plus a second throwing stop; none of the cameras in this
      package do that. The same false-off state was already reachable before this release when such a stop overlaps a normal stop.
    • Closing these needs a structural change — a candidate for a later release.
  • Measured:
    • Deterministic fake cameras with gates.
    • An interleaving explorer (preemption-bounded and exhaustive, plus random schedules) on the first round of these changes found
      no new end-state category. In its three-thread scenario for the original defect (about 7,000 schedules each), the schedules
      where IsGrabbing was true over a stopped inner camera when StopContinuous returned dropped from 597 to 33. A second pass
      that added throwing stops found the opposite place above, a new path into a state that was already reachable.

Wpf — CvDispCtrl's 📸 / ⏯ buttons honour CanExecute (behavior change)

The buttons called Execute directly and never asked CanExecute, so a host that disabled the command during a grab still got a
live, clickable button.

  • They now follow Button.Command semantics:
    • Greyed out and not executed while CanExecute(null) is false.
    • Re-queried on CanExecuteChanged, which may be raised from any thread.
    • The subscription is weak.
  • ⏯ follows StopCommand while IsRunning is true and ContinuousCommand otherwise, so a blocked start never blocks stopping.
  • A click refused by CanExecute re-queries the buttons.
    • A command that turned false without raising CanExecuteChanged greys out on that click.
  • What to do: raise CanExecuteChanged on both edges — when the command becomes blocked and when it becomes runnable again.
    Otherwise the button stays grey until something else re-queries.
  • A CanExecute that throws is treated as runnable, with one warning per command instance.
  • LoadFrameCommand still does not ask CanExecute: its parameter exists only after the file dialog closes. This is documented.
  • Read from the consumer code we could inspect (20 bindings):
    • Two places change behavior, both in the intended direction: a screen whose 📸 / ⏯ commands report false while a grab runs.
    • No binding was found that would leave a button stuck.

Documentation

  • ICam.GrabbingChanged now states which thread each implementation raises on, that notifications can cross, and how to consume
    them asynchronously (re-read IsGrabbing where you decide).
  • ICam.GrabOne lists "another single grab is waiting" among its InvalidOperationException cases, and how a close interrupts a
    waiting grab is stated per implementation.
  • READMEs: the CvDispCtrl command table, and a subscriber note on GrabbingChanged for the Imaging package.

Tests

The core suite grows from 78 to 118 and the WPF suite from 44 to 54. The race tests force each ordering with gates or hooks and
assert that the forced order actually happened before they assert the result; the GevCam and CvDispCtrl tests set up state
directly.

Version

0.30.1 — patch. No public API was added. The behavior changes above correct behavior that broke documented contracts or the
standard ICommand contract.

Checks

  • dotnet build CvInspect.sln -c Release --no-incremental reports 0 warnings (an incremental build reports none when it recompiles nothing)
  • main is an ancestor of this branch (the release-pr-guard job verifies it)
  • the tag will be created on the merge commit, main merged back into dev afterwards, and dev bumped to the next -dev version