Skip to content

fix(crash,gui): record the next GUI crash, and remove two unsafe things found next to it - #108

Merged
donislawdev merged 19 commits into
masterfrom
fix/start-errors-scope-gap-and-crash-capture
Aug 5, 2026
Merged

fix(crash,gui): record the next GUI crash, and remove two unsafe things found next to it#108
donislawdev merged 19 commits into
masterfrom
fix/start-errors-scope-gap-and-crash-capture

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

What this is

A GUI crash left Windows fatal exception: access violation inside tkinter mainloop with no Python frame above it and no session running. This branch makes
the next one diagnosable, fixes two unsafe things found while reading the code
around it, and settles a design question about who is allowed to name the process
in the connection table.

Everything below that says "measured" was measured on a real machine, with the
instrument kept in the repository's private rig directory. Nothing here claims to
have found the cause of that crash.

The crash, made diagnosable

crashlog.arm_native() had exactly one caller - engine.start, on the
real-driver path - justified by a docstring saying a native crash "can only come
from the WinDivert KERNEL DRIVER". That is not true of a Tk process. The crash
report that prompted this exists at all only because that process had started a
capture earlier in its life and arming never disarms.

cli._run_gui now arms as well, before the tkinter import: importing Tk is
itself a native surface, and arming after it would make the guard vacuous on a
runner without Tk.

crashlog.breadcrumb() adds what such a report cannot carry. The context provider
is only ever read by a Python-level failure, so a hard crash says nothing about
what the tool was doing; page, session state and open windows are written before
they are needed and removed with the native file on a clean exit. It is called
from the GUI tick - the one call site that cannot be forgotten when a fourth piece
of state appears - which is affordable only because the de-duplication lives in
crashlog, so an unchanged state costs a dict comparison and no disk.

Verified end to end: a clean exit leaves nothing behind, and a hard exit that
skips atexit leaves both files with the state in them.

gui/crash.py is new because gui/app.py sat exactly on the size ratchet's file
ceiling, and that guard's documented answer is to move code rather than raise the
number. The report context moved with the breadcrumb - they are the two halves of
one job, pulled and pushed.

Two hazards, both measured before being called hazards

gui/theme.py called user32 and dwmapi with no prototypes at all, while a
third function in the same module declared its own properly. This is the class
that already crashed this project once: a truncated 64-bit handle in
driver._advapi and an access violation on CI.

Measured before writing the fix, and the answer is narrower than expected.
GetParent returns the same value either way, and so does the window-style read
for the root window and for a withdrawn Toplevel - the only two shapes
disable_maximize is called on, replayed in its exact order. It differs for a
WS_POPUP window: a transient dialog and the tooltip bubble both have the top bit
set, so ctypes' default signed 32-bit result type reads them as negative.
Reachable, not currently reached - a property of today's call sites rather than of
this code, and one transient non-resizable window would end it.

The bindings move to winenv.user32() / winenv.dwmapi() with full prototypes.
They live there rather than in the GUI because they are Win32 rather than Tk, and
because a guard on them must not need tkinter or it is vacuous on the Linux
runner.

gui/tooltip.py could create a window inside Tk's destroy cascade.
Tooltip._hide is bound to <Destroy>, and _hide_bubble went through the path
that builds a Toplevel when the cached one is gone. Reproduced on real Tk rather
than argued: destroying a window with its bubble still showing creates nothing,
but destroying the bubble first and then the window built a fresh Toplevel inside
the cascade every time. That ordering needs no special setup, since the bubble is
a child of the toplevel it belongs to.

The same cache also grew without bound - keyed by toplevel name, and Tk does not
reuse names within a session, so every window ever opened left an entry holding a
dead Toplevel and Label. Measured at 25 dead entries after 25 open/close cycles,
1 after the fix.

Who names the process in the connection table

The impairment gate resolves against one table - the live socket-event map - while
the connection table's process and PID columns ask that map first and the poller
second, and the GUI then has a third source of its own. So the display can name an
owner the gate never saw, and nothing guarded the difference.

Measured across five sessions with real traffic: the live map answers 97.7-99.3%
of lookups, the poller answers alone for 0-3 ports per run, and in every one of
those the port had a close event with no matching open - a socket already there
when the watcher started. The two sources were never seen disagreeing about a port
the live map went on to learn.

So the fallback names the last known owner of an ended flow, which the live map
deletes on purpose, rather than guessing about a live one. It stays. The strict
variant was rejected because it would cost ended flows their name and lengthen a
per-packet retry, measured at 4.2-5.2 owner lookups per connection row.

The share must not be quoted: the first run gave 14.4% and the next four gave 1.3,
1.7, 2.3 and 0.0. It tracks how much the machine had open before the session
started, which is not a property of anything under test. The shape repeated, and
that is the finding.

The tooltip on that column now says when its answer was taken - the column was
described in the present tense while the name is read once and never re-checked,
which is why a row can still name a program that has since closed.

Guards

New: tests/test_native_prototypes.py and tests/test_owner_attribution.py, plus
three tests for the tooltip and six for the crash path. Eleven new entries in the
mutation registry, every one of them shown to redden the test it names.

Two of those mutations survived at first, and both were information about the
test rather than about the code. The bubble-pruning one, because the fake tkinter
had no winfo_toplevel so every widget shared one cache key - which had also been
making an existing test pass for the wrong reason. The targeting one, because the
real process-wide port table answers nothing for an unused port, so a gate that had
grown a second source underneath looked identical to one that had not.

Both new guards are written as rules rather than examples: one walks the binding
factories and scans the package for any native call made without a prototype, the
other scans for any new consumer of the owner lookup. Adding either without a
decision goes red on its own.

Three things fixed that this branch did not break

An assertion that could never fail: restype is not None for a ctypes function.
It defaults to c_long, and on Windows that is the same object as c_int and as
BOOL, so a truncated handle and a correctly declared boolean are
indistinguishable. That line had been sitting beside the fix for the original
access violation ever since. The width of a result is checkable, and that is what
it checks now.

Two driver tests read a real process-wide mutex despite monkeypatching everything
else, so they failed whenever any session of this tool was live on the machine.
Verified as pre-existing by running them at the commit before this work.

And the root cause behind that: the suite itself leaked the mutex. One test takes
it and then monkeypatches the release path, so the handle survived for the rest of
the pytest process. Watched through two full runs - free, held, free, held. A real
session started right after the tests would have stood down for an instance that
no longer existed. It is released in a fixture now, so the next test to take it
inherits the guarantee.

Checks

Full suite green (1018 passed), GUI smoke green, and the repository's own
pre-commit script reports ready.

donislawdev and others added 19 commits August 4, 2026 19:10
Every start failure was answered with "Run as Administrator". That is the
advice for exactly one Win32 error, and it was shown to an already elevated
window for a completely different one:

    [WinError 433] The specified device does not exist.
    Run as Administrator.

Measured on Windows 11 (elevated, two processes, the WinDivert filter `false`
so no packet is ever diverted): while one instance holds a handle open, a
second instance exiting runs the driver cleanup, the shared service goes to
"stop pending", and every WinDivertOpen fails with 433 until the first handle
closes. A sequential restart does not reproduce it (0/50/250/1000/3000 ms all
opened normally), so the trigger is a second live instance.

driver.py now maps the Win32 codes WinDivertOpen really returns (2, 5, 87,
433, 577, 1275) to i18n keys, keeps the elevation advice for the one error
that means it, and falls back to it only when the process is NOT elevated.
The window and the console read that one table, so the two front ends cannot
drift apart.

Guards: an elevated 433 must not mention Administrator, a non-elevated 5 must
(test_failsafe.py, mutation-proven), the table itself and its key coverage in
both languages (test_driver_windows.py), and the console half
(test_cli_runtime.py, also mutation-proven).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The WinDivert service is machine-wide, so the exit path reached across
process boundaries: an instance closing ran ControlService(STOP) under a
live session of another instance, the service sat in "stop pending", and
every WinDivertOpen on the machine then failed with

    [WinError 433] The specified device does not exist.

until the other handle closed. That is where the reported 433 came from,
and the tool was doing it to itself.

An instance now takes a named kernel object while it holds a real divert
open, and the exit path drops its own before asking whether anybody else
is left. If somebody is, the driver is left loaded and the log says so.
Nothing is lost by standing down: their handle keeps the driver loaded, so
the stop could not have freed the .sys file anyway, and whoever leaves last
still unloads it (convention 22 holds). --cleanup-driver still obeys the
person who typed it, but warns first.

Verified against the real driver on the timeline that used to fail: A
holds a handle, B exits and stands down, C opens in 75 ms where it used to
get 433, and the service disappears once A leaves.

Also in the same area, each measured rather than assumed:

* stop_and_remove reported a success as a failure on every ordinary close.
  DeleteService returns 1072 (already marked for deletion) because
  WinDivert marks its own service at install time; the message said
  "removal failed - it may be in use".
* --doctor called a machine healthy while it was mid-unload - the one
  state in which every start fails. It is a warning now, and names 433.
* A start that arrives while some OTHER program is unloading the driver
  now retries twice (~0.45 s) instead of failing, and says why it waited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It is taken at the first real open and held until the process exits, so it
means "this process has the driver in play", not "has a handle open right
now". An idle instance therefore keeps a closing one from unloading the
driver - deliberate, and the reason is now next to the code instead of in a
reviewer's head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A connection the targeted process opened could be impaired for none of its
life, and the Connections tab showed exactly that: the right process name
with "impaired? no" beside it.

The two sides knew different things. The live SOCKET map learns a socket's
owner as it happens, but targeting only saw it at the resolver's next
rebuild - 0.05 to 0.30 s later - and ordinary TCP data never re-asks the
live map, because asking costs +209..266 ns per packet. A flow that
finished inside that window was therefore never in scope at all. Measured
against the real driver with 12 fresh processes each opening one 0.15 s
connection: 4 of 12 escaped completely. A long-lived process's bursts (20
sequential, 30 parallel) were all caught, which is why this shows up while
browsing: a browser keeps making new processes.

The map now tells targeting instead of waiting to be asked. SocketWatcher
gained one listener slot, wired by the engine so it still knows nothing
about targeting; a port whose pid is already targeted is published at once,
and an unknown pid is adopted by the resolver ahead of the miss floor,
which exists to rate-limit packet misses rather than one name lookup.

The packet path is byte-for-byte unchanged: __contains__ is still a single
frozenset lookup.

Three things this had to preserve, each pinned by a test: refresh still
REPLACES the pid set (a union would erase the measured recycled-pid bound),
a rebuild in flight cannot lose a port an event added meanwhile, and a pid
whose name will not resolve yet is not cached as "not ours" - "I could not
tell" and "not ours" must not share an answer.

Also: the session now subscribes to the event source BEFORE taking its
bootstrap snapshot (a socket created between the two was in neither, and
that reproduced the report), and publishes the watcher before starting its
thread - reordering had opened a window where a concurrent stop() could not
find a running watcher, which the chaos suite caught.

After, same rig, seven runs: 2 of 82 fresh processes still escaped. Those
two are not explained; the obvious explanation was measured and refuted (a
cold name lookup for a new pid is 0.06-0.24 ms), and the instrumentation
that would catch the next one is in the probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…et failures

A new primitive owes a stability pass, and the loud failure is not the one to
look for here. The push path has the watcher thread announcing sockets, the
resolver rebuilding and adopting, and the capture thread reading - two writers
and one reader on one object - so this asserts the outcomes that would
otherwise pass unnoticed: a port lost because a rebuild that started earlier
published a set computed without it, a set growing without bound, a deadlock
between the two locks.

Measured alongside it on a real 60 s session with continuous connection churn
and zero impairment (21894 packets seen, 2961 in scope): RSS 34.6 -> 35.4 MB,
handles 239 -> 243, and none of the new structures above three entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writing the recycled-pid test for the push path found the hole it was written
to rule out. A port the live map announced while a rebuild was walking the
socket table is rescued across that rebuild, because it is newer than the walk
- but the rescue applied to every such port, including one whose pid the very
same walk had just decided is no longer ours. The documented bound, "a
recycled pid is in scope until the next rebuild", quietly became two rebuilds.

A late port now travels with the owner its EVENT named, and is rescued only
while that pid still matches. The first attempt asked the socket table instead
and broke the rescue itself; the event is the better source, because it is the
thing that is newer than the walk.

The chaos suite then caught the other half within seconds: that dict is
written by the watcher thread, and the rebuild was reading it outside the
short lock - a dictionary changed size during iteration, waiting for a busy
machine.

Also covers the lifecycles this kept raising, each as a test: a target that
restarts under a new pid, an application already running when the session
starts (events only carry NEW sockets, so the bootstrap snapshot is what
covers it), and a second session getting its own watcher wired up rather than
inheriting the first one's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ocess

Windows announces some connections twice through the SOCKET layer: once for
the program that opened it, and once more for the kernel's own half, carrying
ProcessId 4. Traced per port on a live session:

    BIND/pid116724, CONNECT/pid116724, CONNECT/pid4, CLOSE/pid116724

The map applied every add-event as authoritative, so the port moved to System
in the middle of a live connection. Targeting dropped it at the next rebuild,
because System does not match the target, and the connection row was stamped
with pid 4 - which is where the "System" owner in the Connections tab came
from. Ports were seen leaving scope 15-32 ms into a 240 ms flow.

One cause, both open symptoms: the residual scope misses that survived this
week's targeting work, and the wrong owner in the table. It has been there
since the live map shipped in July, not since this week.

An add-event from pid 4 is now refused for a port a user process already
holds. It stamps no evidence, so the poller's snapshot can still correct the
entry - the rule the unmodelled event kinds already followed. A port System
takes first is still System's: it genuinely owns 139 and 445.

How it was found is worth as much as the fix. The rig now records when a port
enters scope AND WHEN IT LEAVES, and the departure is what pointed at the map;
the first hypothesis - TIME_WAIT rows attributed to System - was measured and
refuted, because TIME_WAIT carries pid 0, which is already filtered.

Measured end to end, 12 fresh processes each opening one 0.15 s connection:
4 of 12 missed before the push path, 2-4 per run with it, 1 of 96 across eight
runs after this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every automated targeting test runs against a fake socket table with a
hand-written ancestors(). That is the right shape for a unit test, and it
cannot fail the way the real thing fails: the live SOCKET map, the resolver's
timing and the driver are all absent, which is exactly where every targeting
bug found by hand this month lived.

This starts a real session against a loopback echo server it runs itself,
targets one child process by pid, and asserts both halves - the target's
connection is in scope, and a second, untargeted child's is not. Zero
impairment, a filter narrowed to the echo port, about ten seconds.

Verified by mutation rather than asserted: with __contains__ patched to never
hit it exits 1 and names the failure, and the unmutated tree exits 0. The
docstring also states what it cannot catch, because the connection log's flag
is sticky and these children talk for seconds - a flow that loses scope part
way through needs the per-flow timing rig, which is not a ten-second job.

Deliberately NOT wired into a workflow yet: the Windows runner is shared, and
a real-driver step earns its place there after it has run clean by hand for a
while, not before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The buffer help sheet told the user to "enter how many ms of lag you
want". That holds for a small buffer and overpromises for a large one:
the number is the most the queue can add, and light traffic may never
reach it.

Measured against a real peer at a 500 KB/s limit, with a client sized to
each buffer: 100 ms gives 0.98x of the figure typed, 300 ms 0.88x,
1000 ms 0.94x and 2000 ms 0.99x when four downloads push together - but a
single ordinary download only reaches about a quarter of a 2000 ms
buffer, because it never keeps enough data in flight to fill the queue.

One sentence added to dialogs.buffer_help in both languages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing guard writes a config with today's code and reads it with
today's code. Its docstring says as much - it holds by construction - so
it cannot see what actually breaks for a user: a file saved last year,
opened by the build they just installed. The on-disk formats are frozen
contracts and nothing was holding an old one against a new reader.

tests/data/legacy/ now carries files produced by checking out v0.2.0,
v0.3.0 and v0.4.0 and running THEIR writers, rather than files written
by hand to look old. The format really did move: v0.4.0 added
narrow_filter, so the v0.2.0 config is missing a key this build expects,
and that is the case worth covering.

Five checks, all iterating the corpus so a new release needs a directory
and no code change: the corpus is not empty, each config loads with
absent keys filled from defaults instead of rejected, each profile keeps
a field's own default where the file omits it, each saved window state
survives, and each repro command still parses.

Both directions are registered in the mutation registry. Dropping the
default fill in load_config_file fails with "missing ['narrow_filter']".
Re-arming the historical zero-fill in ProfileStore._clean fails naming
buffer, the field where 0 means an unbounded queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test already derived its list from FIELD_DEFS instead of naming
fields, but that was a claim about a guard whose earlier version let
narrow_filter into the registry and left it clickable for a whole
session while staying green.

Registered in MUTATIONS so the claim is repeatable: replacing the
registry read in ControlForm.is_locked with a hardcoded key fails the
test, and the failure names narrow_filter - the field that historically
slipped past.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A hard crash in a Tk process leaves no Python traceback, and until now the
file that catches those was opened only by `engine.start` on the real-driver
path. The docstring justified that with "a native crash can only come from
the WinDivert KERNEL DRIVER", which is not true of a Tk process: the crash
that prompted this is an access violation inside `tkinter mainloop` with no
Python frame above it and no session running. It was captured at all only
because that process had started a capture earlier and arming never
disarms - a GUI that never ran a session would have left nothing.

`cli._run_gui` now arms too, before the tkinter import: importing Tk is
itself a native surface, and arming after it would make the guard vacuous
on a runner without Tk. The cost is stated in the docstring rather than
discovered later - a GUI killed from Task Manager now leaves an empty
native-crash file where before that took a real session first.

`crashlog.breadcrumb()` adds what such a report cannot carry. The context
provider is only ever read by a Python-level failure, so a hard crash says
nothing about what the tool was doing; page, session state and open windows
are now written before they are needed and removed with the native file on
a clean exit. It is called from the GUI tick - the one call site that
cannot be forgotten when a fourth piece of state appears - which is
affordable only because the de-duplication lives in crashlog, so an
unchanged state costs a dict comparison and no disk.

`gui/crash.py` is new because `gui/app.py` sat exactly on the size
ratchet's file ceiling and that guard's answer is to move code, not to
raise the number. The report context moved with the breadcrumb: they are
the two halves of one job, pulled and pushed.

Both halves are in the mutation registry - dropping either call reddens
exactly the named test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… defaults

`gui/theme.py` called user32 and dwmapi through ctypes with no argtypes or
restype at all, while a third function in the same module declared its own
properly - which is what a rule enforced by nothing looks like after a while.
This is the class that already crashed this project once: a truncated
64-bit SC_HANDLE in `driver._advapi` and an access violation on CI.

The bindings move to `winenv.user32()` / `winenv.dwmapi()`, cached with full
prototypes like `_advapi`. They are Win32 rather than Tk, and putting them
there keeps their guard from needing tkinter - a guard that cannot run on a
runner without Tk is not a guard.

Measured before writing the fix, and the answer is narrower than expected.
`GetParent` returns the same value either way, and so does the style read
for the root window and for a withdrawn Toplevel - the only two shapes
`disable_maximize` is called on, replayed in its exact order. It differs for
a WS_POPUP window: a transient dialog and the tooltip bubble both have the
top bit set, so the default signed 32-bit restype reads them as negative.
Reachable, not currently reached - a property of today's call sites rather
than of this code. No claim is made that this caused the reported crash.

`tests/test_native_prototypes.py` guards the rule in both directions: every
function a factory declared carries argtypes, and no new direct
`windll.lib.Func` call appears outside a named allowlist of the eleven that
already existed. What the scan cannot see is written down rather than left
to be discovered - the first draft missed a bare `windll.` and a mutation
walked past it.

It also replaces an assertion that could never fail. `test_driver_windows`
checked `restype is not None` for six functions; ctypes defaults restype to
c_long and on Windows `c_long is c_int is BOOL`, so a truncated handle and a
declared BOOL are the same object. The width of a result is checkable, and
that is what it checks now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Tooltip._hide` is bound to <Destroy>, and `_hide_bubble` went through
`_bubble_for`, which BUILDS a Toplevel when the cached one is gone. Creating
a widget inside Tk's destroy cascade is not safe and there is no reason to
do it: hiding something that no longer exists is already done.

Reproduced on real Tk rather than argued. Destroying a window with its
bubble still showing creates nothing - the bubble outlives the widget's
<Destroy>. Destroy the BUBBLE first and then the window, and a fresh
Toplevel is built inside the cascade every time; that ordering needs no
special setup, since the bubble is a child of the toplevel it belongs to.
This is not claimed to be the cause of the reported crash.

The same cache also grew without bound. It is keyed by toplevel name, Tk
does not reuse names within a session, and nothing ever pruned it, so every
window ever opened left an entry holding a dead Toplevel and Label:
measured at 25 dead entries after 25 open/close cycles, 1 after the fix.
Pruning happens when a bubble is created, i.e. once per window.

The fake tkinter grew a real `winfo_toplevel`. Without it `W.__getattr__`
answered with a no-op returning None, and the bubble cache keys on `str()`
of that result - so every widget in every test shared the key "None". "One
bubble per window" was true by accident, and the cache growth was invisible
here while real Tk showed 12 entries for 12 windows. The pruning mutation
survived until this was fixed, which was information about the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…amed

The impairment gate resolves against ONE table - the live socket-event map -
while the connection table's process and PID columns go through
`engine._live_pid`, which asks that map first and the poller second, and the
GUI then has a third source of its own. So the display can name an owner the
gate never saw, and nothing guarded the difference.

Measured before deciding anything, five 20 s sessions with real traffic:
the live map answers 97.7-99.3% of lookups, the poller answers alone for 0-3
ports per run, and in every one of those the port had a CLOSE event with no
ADD - a socket already open when the watcher started. The two sources were
never seen disagreeing about a port the live map went on to learn. So the
fallback names the last known owner of an ended flow, which the live map
deletes on purpose, rather than guessing about a live one. It stays.

The share must not be quoted: the first run gave 14.4% and the next four
gave 1.3, 1.7, 2.3 and 0.0. It tracks how much the machine had open before
the session started, which is not a property of anything under test. The
shape repeated; that is the finding.

The guard is what the measurement cannot provide, since it describes today.
Five tests, of which one has to outlive this session: an AST scan that
reddens when a NEW caller of `_live_pid` appears, because a fifth consumer
would inherit the fallback silently and the failure it produces - a row
naming a process nothing impaired - is the report this started from.

One mutation survived first and that was information about the test: the
real process-wide poller answers None for an unused port, so a gate that had
grown a second source underneath looked exactly like one that had not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tooltip described the column in the present tense - "the process that
owns the local port" - while the name is read once, when the connection
first appears, and never re-checked. That is why a row can still name a
program that has since closed, and with the tooltip saying otherwise it
looked like a mistake instead of the design.

Deliberately does not mention the slower second source behind it. Measured
across five sessions, that source answers for nought to three ports a run
and its answer is right in every one of them, so naming it would add jargon
about something that is not a problem. The missing word was the tense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_release_on_exit_swallows_a_cleanup_fault` and
`test_cleanup_driver_stops_every_installed_service` monkeypatch the service
manager, the platform and the admin check - and then call code that opens a
real process-wide named mutex. With any session of this tool live anywhere
on the machine, the first took the stand-down path and never reached the
fault it exists to exercise, and the second collected an extra warning line
and failed its per-service assertion.

Verified as pre-existing rather than assumed: both fail the same way at the
commit before this branch's work. The file's docstring promises the tests
run "without a real Service Manager"; they were leaking a different global.

Also lists gui/crash.py in both README layout trees, and shortens a
changelog entry that had grown past the word ceiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_the_exit_path_stands_down_when_another_instance_is_using_the_driver`
calls `driver.mark_driver_used()`, which takes the real machine-wide
`WinDivertInUse` marker, and then monkeypatches the release path so it never
runs. The handle therefore survived for the rest of the pytest process.

Measured by watching the mutex through two full runs: free, HELD from a
minute into the run, free, HELD again. That is the root cause behind the two
driver tests that were failing here on machine state, and it reached past
the suite - a real session started right after the tests would have stood
down for an instance that no longer existed.

The release now happens in an autouse fixture, so the next test to call
`mark_driver_used` inherits the guarantee instead of having to know about
it. The per-test monkeypatches added earlier stay: they cover an EXTERNAL
holder - a real session running while the suite does - which no fixture of
ours can release.

Verified: full suite green, and the marker is free the moment it ends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y correct

The Linux runner rejected `test_native_prototypes.py`, and the mistake is
worth naming rather than just fixing. Its off-Windows branch asserted that
all three binding factories "degrade to None", generalised from the two in
`winenv` that do. `driver._advapi` does not - it goes straight to
`ctypes.WinDLL` and raises, because every one of its callers sits behind an
`is_windows()` guard of its own. The branch now checks the right property
per factory, and says why they differ.

The general failure is that an off-Windows branch cannot be executed by
running pytest on Windows, so it gets reviewed and never run - which is how
a file written specifically to be correct off Windows shipped green here and
red on CI.

`internal_tools/as_linux.py` closes that. It strips `ctypes.windll` and
`ctypes.WinDLL` plus both `is_windows()` helpers and runs a test module
function by function. Removing only the first is not enough: a factory that
builds a `WinDLL` directly still succeeds, and an earlier version of the
harness therefore reported a different failure from the one CI reported.

Validated the only way a harness may be: the known-bad version was put back
and it reproduced CI's exact AttributeError, while the fix passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 688e126 into master Aug 5, 2026
7 checks passed
@donislawdev
donislawdev deleted the fix/start-errors-scope-gap-and-crash-capture branch August 5, 2026 19:49
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