Skip to content

Close the last two local lifecycle-invariant items: (c) PnP cross-check, (d) unowned-backend affordance - #29

Merged
potpiemuncher merged 3 commits into
mainfrom
lifecycle/pnp-crosscheck-and-unowned-backend-card
Aug 1, 2026
Merged

Close the last two local lifecycle-invariant items: (c) PnP cross-check, (d) unowned-backend affordance#29
potpiemuncher merged 3 commits into
mainfrom
lifecycle/pnp-crosscheck-and-unowned-backend-card

Conversation

@potpiemuncher

Copy link
Copy Markdown
Owner

Closes the two local items the Phase 3.1 gap-diff left open after 3.3: the optional hardening for invariant (c) and the diagnostics affordance for invariant (d). With (b) filed upstream as hbashton/VIIPER#7, every invariant in lifecycle-invariants.md is now Present, prevented by architecture with its residue surfaced to the user, or awaiting review elsewhere.

(c) Ask Windows before believing the backend is idle

The stop-on-exit decision proved "idle" entirely from VIIPER's census — the backend's own bookkeeping. A devnode Windows still shows after the backend has forgotten it (the problem-24 phantom the old fork's present-only SetupAPI probe existed for) would have passed it.

CmTreePnpAbsenceProbe walks the CM device tree under every present devnode carrying ROOT\USBIP_WIN2\UDE — all of them, not the first. Root hubs are descended; every non-hub node is reported as an attached device with its problem code, and not descended into. Position rather than identity, deliberately: a pad attached over USB/IP carries the same VID/PID as a real one on a physical port, and the personas VIIPER can host make an ID list a moving target.

ViiperBackendStopPolicy.Decide takes the probe as a deferred Func and consults it only after every census gate has passed — a cross-check on the final verdict, not a routine exit cost — then judges it the way this policy judges everything: devices present, unproven, a null result and a thrown exception all leave the backend running.

One intentional asymmetry with the census: a missing controller is proven absence (nothing can be attached through a controller that is not there), while a failed census stays a failure (devices can be alive behind an API that will not answer).

(d) Say something when a backend outlives the session that started it

A session that dies hard leaves its backend and pads running; the next session correctly refuses to touch them, since ownership is (pid, start time) held in memory only. That refusal was silent — a stale virtual controller with no in-app explanation.

ViiperUnownedBackendPolicy classifies the backend on the API port (managed by this session / unowned idle / unowned serving this session's pads / unowned holding unaccountable devices / unreadable), and a Backend process card in the Settings VIIPER section renders it with holdings listed.

  • The card does not pretend to know more than it does: leftovers of a dead session and another program's live controllers are indistinguishable from here, so the headline gives both readings and the confirmation names what happens if the second is true.
  • The stop is offered exactly when the report says so — unowned, holdings readable, none of them this session's live controller. Unreadable census ⇒ no button: consent to stop a backend is consent to what it is holding.
  • The gate re-runs at commit time, so a stale card cannot stop a backend that has since started serving this session.
  • The process is identified as the owner of the API port's listening socket (GetExtendedTcpTable), never by executable name.
  • Startup logs one line when an unowned backend holds devices, naming the Settings path.

Deliberately not a lifecycle change: nothing happens without a click, and the exit path's refusal to touch unowned backends is untouched.

Verification

Suite: 853 passed / 0 failed (CI filter), from 812. Fresh --no-incremental rebuild: the same 17 known warnings as main, none new.

Both halves follow the pure-decision / OS-probe split, so the policies are covered by fakes: cross-check ordering and each fail-closed path, the probe's contract (including the real walk, which must answer and never throw on any machine), classification, the locator's pure half, the commit-time gate (ping/census/pid all seamed — the real port is never touched), and the card's wording.

The (d) card was also driven for real, since WPF binding failures are silent. Local isolated run against a stand-in API server serving one foreign dualsense on bus 0; verified through the UIA tree: card rendered the UnownedInUse headline with bus 0 device 7 (dualsense) - not created by this session under "What it is holding"; the confirmation carried the holdings and the losing-a-controller warning; confirming it stopped the right process and the card refreshed to "No VIIPER backend is running." with Backend stopped (... had to be killed). Both log lines fired.

That run incidentally proved why the locator reads the socket table: the stand-in was a powershell process, not viiper.exe, and GetExtendedTcpTable identified it correctly by socket ownership — name matching would have found nothing to stop.

Still open, by design

🤖 Generated with Claude Code

potpiemuncher and others added 3 commits July 31, 2026 22:33
The stop-on-exit decision proved "idle" entirely from VIIPER's census, which
is the backend's own bookkeeping. A devnode Windows still shows after the
backend has forgotten it -- the problem-24 phantom the old fork's present-only
SetupAPI probe existed for -- would have passed it. That is the gap lifecycle
invariant (c) names: do not declare teardown finished on a probe that cannot
distinguish "gone" from "cannot tell".

CmTreePnpAbsenceProbe walks the Configuration Manager tree under every present
devnode carrying the UDE controller's hardware ID (ROOT\USBIP_WIN2\UDE -- all
of them, not the first, or a device under a second controller instance would
be invisible to a probe whose whole point is proving absence). Root hubs are
descended into; every non-hub node found is reported as an attached device
with its problem code, and not descended into, because a composite pad's
interface and HID children are that same device rather than more of them.

Position rather than identity, deliberately. A pad attached over USB/IP
carries the same VID/PID as a real one on a physical port, and the personas
VIIPER can host make an ID list a moving target; what makes a devnode
usbip-attached is living under the emulated controller.

ViiperBackendStopPolicy.Decide takes the probe as a deferred Func and consults
it only after every census gate has passed -- a cross-check on the final
verdict, not a routine exit cost -- and judges it the way this policy judges
everything: devices present, unproven, a null result and a thrown exception
all leave the backend running.

One asymmetry with the census is intentional. A missing controller is proven
absence, because nothing can be attached through a controller that is not
there; a failed census stays a failure, because devices can be alive behind an
API that will not answer. The probe itself never throws -- every failure is an
Unproven verdict carrying whatever stopped it, since that string is the only
lead whoever reads the log will get.

Ten tests: cross-check ordering (a census-level refusal must settle the matter
without touching the device tree), each fail-closed path, the wording that
carries the evidence, and the real walk, which must answer on any machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
If a session dies hard while owning a backend it started, the backend and any
attached pad survive it. The next session sees a backend it did not start and
leaves it alone, which is right -- ownership is (pid, start time), held in
memory only, and a crashed session must not hand a later one a licence to kill
somebody else's process. But the refusal was silent, so the user was left with
a stale virtual controller and no in-app explanation of why nothing cleaned it
up. Lifecycle invariant (d) has no dangerous case in this architecture; this
is its untidy residue, and it belongs in front of the user rather than in a
comment.

ViiperUnownedBackendPolicy classifies the backend on the API port: managed by
this session, unowned and idle, unowned but serving this session's own pads,
unowned and holding devices this session cannot account for, or unreadable. A
Backend process card in the Settings VIIPER section renders the verdict with
the holdings listed.

The card does not pretend to know more than it does. Leftovers of a dead
session and another program's live controllers are indistinguishable from
here, so the headline gives both readings and the confirmation names what
happens if the second one is true.

The stop is offered exactly when the report says so: unowned, holdings
readable, and none of them this session's live controller. An unreadable
census offers nothing, because consent to stop a backend is consent to what it
is holding, and that could not be shown. The gate re-runs at commit time, so a
card left open while the world moved cannot stop a backend that has since
started serving this session.

The process is identified as the owner of the listening socket on the API port
via GetExtendedTcpTable, never by executable name: name matching would find
any viiper.exe, including one serving a different port, while the socket table
names the process that actually answered. Stopping it is the clean unplug path
for whatever is still attached -- the USB/IP peer disappears and the driver
surprise-removes the devices, the same order VIIPER's own exit produces.

Startup logs one line when an unowned backend is holding devices, naming the
Settings path that can act on it. Deliberately still not a lifecycle change:
nothing happens without a click, and the exit path's refusal to touch unowned
backends is untouched.

Thirty-one tests across the classification, the locator's pure half, the
commit-time gate (ping, census and pid all seamed, so the real port is never
touched) and the card's wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verdicts for (c) and (d) in lifecycle-invariants.md, following the 3.3 pattern:
the closing callout first, the gap as originally found left underneath, so the
document keeps reading as the record of what was diffed rather than becoming a
description of the current code.

With (b) filed upstream, every invariant in that document is now Present,
prevented by architecture with its residue surfaced to the user, or awaiting
review elsewhere. What stays open is named: (e)'s check-then-kill window, which
is not closable from the client side, and the VIIPER fix riding review.

The PLAN-PROGRESS entry records the local UI run as well as the suite, because
WPF binding failures are silent and no unit test would have caught a card that
did not render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@potpiemuncher
potpiemuncher merged commit 78561c4 into main Aug 1, 2026
4 checks passed
@potpiemuncher
potpiemuncher deleted the lifecycle/pnp-crosscheck-and-unowned-backend-card branch August 1, 2026 03:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant