Close the last two local lifecycle-invariant items: (c) PnP cross-check, (d) unowned-backend affordance - #29
Merged
potpiemuncher merged 3 commits intoAug 1, 2026
Conversation
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
deleted the
lifecycle/pnp-crosscheck-and-unowned-backend-card
branch
August 1, 2026 03:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdis 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.
CmTreePnpAbsenceProbewalks the CM device tree under every present devnode carryingROOT\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.Decidetakes the probe as a deferredFuncand 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.ViiperUnownedBackendPolicyclassifies 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.GetExtendedTcpTable), never by executable name.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-incrementalrebuild: the same 17 known warnings asmain, 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
dualsenseon bus 0; verified through the UIA tree: card rendered theUnownedInUseheadline withbus 0 device 7 (dualsense) - not created by this sessionunder "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." withBackend 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
powershellprocess, notviiper.exe, andGetExtendedTcpTableidentified it correctly by socket ownership — name matching would have found nothing to stop.Still open, by design
🤖 Generated with Claude Code