Skip to content

Mock Driver - #15

Open
vlabo wants to merge 24 commits into
mainfrom
feature/mock-driver
Open

vlabo wants to merge 24 commits into
mainfrom
feature/mock-driver

Conversation

@vlabo

@vlabo vlabo commented Oct 21, 2026

Copy link
Copy Markdown
Owner

No description provided.

vlabo added 7 commits October 21, 2026 14:27
Make the device API shared-reference based. `get_device` now hands out
`&'static Device` instead of `&'static mut`, so callout threads and the
read/write paths can share one device soundly. Every field is
interior-mutable: `FilterEngineCell` wraps the filter engine (it cannot
live behind the IRQL-raising spin lock, since WFP transactions must run
at PASSIVE_LEVEL), `IdCache::next_id` becomes an `AtomicU64` assigned
under the values lock, and `clean_ended_connections` returns owned
vectors instead of handing out `&mut` to reused buffers.

Bounds-check command parsing. `parse_type` no longer panics on an empty
buffer, and `as_type` rejects buffers shorter than the target struct
instead of reading out of bounds; the parse functions now return
`Option` and the device logs and drops truncated commands.

Replace the `MaybeUninit::zeroed().assume_init()` log statics with
const-initialized ones, drop the unneeded `unsafe` blocks, and fix the
`crit!` macro, which called a stale two-argument `add_line`.

Return `STATUS_FAILED_DRIVER_ENTRY` rather than -1 when device
initialization fails.

Add deterministic Rust <-> Go differential tests of the wire format:
both sides derive field values from the record index with the same
splitmix64-style hash, so any divergence in endianness, field order,
type width, or struct size shows up as a mismatch. Wire the new tests
and fixtures into the `Justfile` targets.
Make the device API shared-reference based. `get_device` now hands out
`&'static Device` instead of `&'static mut`, so callout threads and the
read/write paths can share one device soundly. Every field is
interior-mutable: `FilterEngineCell` wraps the filter engine (it cannot
live behind the IRQL-raising spin lock, since WFP transactions must run
at PASSIVE_LEVEL), `IdCache::next_id` becomes an `AtomicU64` assigned
under the values lock, and `clean_ended_connections` returns owned
vectors instead of handing out `&mut` to reused buffers.

Bounds-check command parsing. `parse_type` no longer panics on an empty
buffer, and `as_type` rejects buffers shorter than the target struct
instead of reading out of bounds; the parse functions now return
`Option` and the device logs and drops truncated commands.

Replace the `MaybeUninit::zeroed().assume_init()` log statics with
const-initialized ones, drop the unneeded `unsafe` blocks, and fix the
`crit!` macro, which called a stale two-argument `add_line`.

Return `STATUS_FAILED_DRIVER_ENTRY` rather than -1 when device
initialization fails.

Add deterministic Rust <-> Go differential tests of the wire format:
both sides derive field values from the record index with the same
splitmix64-style hash, so any divergence in endianness, field order,
type width, or struct size shows up as a mismatch. Wire the new tests
and fixtures into the `Justfile` targets.
The `driver` crate was both the driver logic and the kernel image
build root: it carried crate-type = "staticlib", the global
allocator, the panic handler, the CRT stub and the exported
`DriverEntry`. That glue only makes sense for a kernel build, and it
blocks building the same logic as a plain library for host-side
fuzz/simulation harnesses.

Move it to a new `driver/kernel` crate, the only crate that produces
the shipped driver.lib (built as driver_kernel.lib). It depends on
`driver` with the `real_wdk` feature, so nothing in that graph can
reach a mock wdk. Keep it free of logic so the code tested on the
host is the code that ships.

`driver` becomes a plain library whose `wdk` and `windows-sys`
dependencies are optional behind the default `real_wdk` feature.
`entry.rs` now names kernel types through the new `wdk::kernel_types`
re-export instead of `windows_sys` directly, so a mock can supply
API-compatible stand-ins under the same path.

Update the Justfile, CI workflow and release packaging for the moved
build root and the new lib name.
Make the callout entry points, device, caches and packet helpers public,
add IdCache::pending_ids and Default impls for the public caches, and
drop the real_wdk feature (the mock now lives in its own crate). No
mock code and no behaviour change in the driver.
mock/driver compiles the unmodified driver/src against mock/wdk (a host
mock of the Windows kernel API) through a Cargo dependency rename, so
tests run the production code with only the Windows API swapped out.
mock/harness loads it through the real driver_entry/DriverUnload and
holds the fuzzing API, the in-process simulation and the property tests;
mock/fuzz holds the cargo-fuzz targets. Nothing in driver/ or wdk/ refers
to mock/.
zf-sim loads the real driver through mock/harness and exposes it on a
named pipe (Unix socket on Linux/macOS); kext_interface gains the
matching pipe transport (OpenPipe, MockKextService, SimConn) and
kext_tester can spawn and drive it. Adds the sim-* Justfile recipes and
the sim-linux CI job.
@vlabo vlabo changed the title Feature/mock driver Mock Driver Oct 21, 2026
vlabo added 5 commits October 26, 2026 15:24
GetConnectionsUpdate was comparing last_accessed_timestamp against the
request timestamp with the wrong sign, so it reported the connections
that had *not* seen traffic since the last update. Invert the check and
take the cursor from the driver's clock before the walk, sending it back
in ConnectionUpdateEnd so user space can pass it to the next request
without interpreting it. The cursor is inclusive, so a connection
touched during the walk is reported again rather than missed. Ended
connections are now skipped: their end event already carried the final
counters.

This gives ConnectionUpdateEnd a payload, so parseEmptyInfo goes away
and the Go side parses it like any other event. The diff-test corpus
gains a seventh record type to cover it.

Also fix the payload layer for inbound ALE: it is cloned after retreating
over the IP and transport headers, so the payload starts at the network
layer, not the transport layer. Only outbound ALE hands over the packet
from its transport header.
# Conflicts:
#	driver/src/device.rs
#	driver/src/entry.rs
#	driver/src/id_cache.rs
#	kext_interface/diff_test.go
#	protocol/src/difftest.rs
The zf-sim daemon hard-coded its scenarios in Rust: random traffic from
a producer, authored runs as CBOR files emitted by a sample builder, and
a Go agent that spawned the daemon to drive either. Adding a case meant
a new Rust builder and a new fixture, and asserting on the driver's
state from the agent side was not possible at all.

Replace it with `zfsim`, a Python extension module (PyO3, abi3) that
loads the same driver through mock/harness in-process. The Rust side is
now only an interface -- callouts, the user-space channel, driver state
-- and scenarios, traffic, seeds and assertions live in Python. A script
plays the OS and the agent at once, or serves the driver on a named pipe
(a Unix socket on Linux/macOS) to the real Go agent. sim/tests holds the
scenario tests, sim/scripts/serve.py the slow readable traffic for a
real agent, and sim/docker runs the Linux agent against it in a
container.

Build with plain cargo plus a copy (build.py), no maturin; the cdylib is
just a renamed extension module. Drop the CBOR scenario format, the
producer, the sample emitter and their sim-* recipes; `just sim-test`
now runs the Python tests and CI calls it in place of the emit/replay
pair.

kext_tester only attaches: the script owns the driver's lifecycle, so
MockKextService and the spawn path go away.

Supporting fixes, needed for scripts to parse what the driver emits:
the structured packet builders now produce well-formed packets (real
TCP/UDP headers, IP and transport checksums, correct lengths) and hand
ALE the data offset WFP would; Event::decode in mock/harness parses an
event payload per its info_type, round-trip tested against the protocol
serializers; and the mock clock returns system time in the kernel's
1601 epoch rather than the Unix one.
Package `zfsim` as a wheel so colleagues can `pip install` the
driver-over-mock-kernel simulator instead of building it from source.

- sim/docker/wheels.Dockerfile cross-builds the Windows, Linux and
  macOS x86_64 abi3 wheels on one Linux machine with maturin, zig
  and cargo-xwin, so a wheel build needs nothing but Docker.
- `just sim-wheels [version]` runs that build into sim/dist/; the
  version defaults to the driver's (kext_interface/version.txt),
  which build.py now prints with --version.
- sim/pyproject.toml gains the maturin build-system and project
  metadata; the dev extension built by build.py is excluded so it
  cannot ride along in a wheel for another platform.
- pyo3 gets generate-import-lib, so the Windows wheel links against
  python3.dll without a Windows Python install.
- .github/workflows/zfsim-wheels.yml builds the same container on
  every published release, installs the Windows and Linux wheels
  into a clean Python, runs the scenario tests against them, and
  attaches all three wheels to the release.

Day-to-day development is unchanged: `just sim-build` still does a
plain cargo build and copy, with no maturin involved.
Build the wheels on every PR push and publish them as the
pre-release `pr-<number>`, versioned `<driver>.dev<run>` so each push
installs as an upgrade of the last. A sticky PR comment carries the
`pip install` command per OS; both the release and the comment are
replaced on every push and removed when the PR closes.

PRs from forks get no write token, so they keep only the run
artifact. Runs for a PR are cancelled by the next push, while release
runs always finish.
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

zfsim 2.0.2.dev2 (32affab)

Install the build of this PR for your OS (any CPython 3.9+):

macOS (Intel)

pip install --force-reinstall https://github.com/vlabo/ZenithFence/releases/download/pr-15/zfsim-2.0.2.dev2-cp39-abi3-macosx_10_12_x86_64.whl

Linux

pip install --force-reinstall https://github.com/vlabo/ZenithFence/releases/download/pr-15/zfsim-2.0.2.dev2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Windows

pip install --force-reinstall https://github.com/vlabo/ZenithFence/releases/download/pr-15/zfsim-2.0.2.dev2-cp39-abi3-win_amd64.whl

Updated on every push; removed when the PR closes.

vlabo added 12 commits October 27, 2026 10:47
zfsim is the mock driver with a Python face: it loads the real
driver over mock/wdk through mock/harness and serves it to an
agent. It lived in a workspace of its own next to mock/, which
duplicated the lockfile (sim/Cargo.lock) and the build of the
very crates it depends on.

It is now mock/sim, a fourth member of the mock/ workspace
alongside wdk, driver and harness, sharing mock/Cargo.lock and
mock/target. build.py copies the cdylib out of mock/target, and
pyo3 stays out of the driver build as before: driver/kernel
depends on nothing under mock/.

Paths updated throughout: Justfile sim-* recipes, .gitignore,
go.work, the CI workflows (the Swatinem cache now keys on the
mock workspace), the wheels Dockerfile and its build context,
and the doc comments that pointed at sim/.

rustdoc-gen moves mock/sim into RUSTDOC-mock.md, leaving
RUSTDOC-tools.md to release/ alone; the stale mock/fuzz entry
goes with it (those targets were removed earlier).

Also drop protocol-test.yml. Its two jobs were a subset of what
`just test` already runs in pr-checks.yml, and build.yml no
longer triggers on every branch push: a push to a PR branch ran
both the push and pull_request copies of every job.
Build on `release: published` as well, selecting the release profile for
those runs (PRs and main keep building debug, like `just link`). A new
release-only `publish` job zips the linked driver.sys with its PDB,
names the zip after the tag and uploads it to the release.

Also pin the workflow's default permissions to `contents: read`, with
`contents: write` granted only to the publishing job, and skip the
kext_interface compile check on release runs.
The driver only ever needed a device object to talk to user space, but
it pulled in KMDF to get one: `pm_InitDriverObject` in `c_helper` wrapped
WdfDriverCreate/WdfDeviceCreate, which bound the image to WdfLdr and the
FxDriverEntry stub, and the context-space plumbing around it was unused
(the device lives in a global).

Create the device directly instead. `init_driver_object` now calls
WdmlibIoCreateDeviceSecure with SDDL_DEVOBJ_SYS_ALL_ADM_ALL, so only the
system and administrators can open `\\.\ZenithFence`, and creates the
symbolic link itself. `delete_device` undoes both, and is called from
`driver_unload` after the callouts are gone as well as from the
DriverEntry failure path, which the unload routine never reaches.

With no framework dispatching for us, the driver has to answer the
requests it previously inherited: add create/cleanup/close handlers that
complete the IRP successfully, via a new `irp_helpers::complete_empty`,
and set DO_BUFFERED_IO so read/write requests arrive in SystemBuffer the
way `irp_helpers` already reads them. The link entry point moves from
FxDriverEntry to GsDriverEntry in every place that invokes link.exe.

Delete `c_helper/` and the WdfDriverEntry/WdfLdr import libraries.

Also switch the driver's clock from system time to interrupt time.
`get_system_timestamp_ms` returned wall-clock time, so an NTP step or a
user changing the clock shifted every cached timestamp: connection
eviction could wipe the cache or never run, and bandwidth windows could
go backwards. `get_monotonic_ms` reads KUSER_SHARED_DATA.InterruptTime,
which only counts forward from boot. Cache cutoffs now saturate, since
the clock starts near zero when the driver loads with the system.
Replace `ArrayHolder` with `PendingEvent`. The old type was a `RefCell`
behind a hand-written `Sync` impl, so two reads racing on the leftover
buffer could each take it and free it twice, and a failed `try_borrow`
silently dropped the rest of an event. The new type keeps the
serialized event and how many bytes were already sent under the
spin-lock mutex, and `Info::into_bytes` hands the buffer over instead
of copying the tail.

Back `FilterEngineCell` with a kernel mutex. It was an `UnsafeCell`
with a fake `Sync`, relying on the filter reset queue and the teardown
never overlapping; a KMUTEX enforces it, and unlike a spin lock or a
FAST_MUTEX it leaves the IRQL where it is, so the holder can still call
the PASSIVE_LEVEL filter engine routines and wait. Delete the unused
`fast_mutex.rs`, which was never a module and did not compile.

Never leave a pended packet uninjected. An out-of-range verdict byte
made `FromPrimitive` return None and the match fell through without
injecting, so the connection waited forever; so did a redirect verdict
whose connection had been cleared from the cache in the meantime. Both
now block and inject.

Add a `Drop` to `ClassifyDefer` that completes an operation resolved
nowhere else. WFP then classifies the connection again and the verdict
comes from the cache, rather than the thread hanging in the kernel and
the callout refusing to unregister.

Check `can_set_action` in the ALE and packet callouts before touching
the packet. Once an earlier filter has made a final decision the
classify-out right is gone, and pending or absorbing there is both
ignored and, for a pend, unanswerable.

Add the connection to the cache before queueing its event, in both ALE
auth paths. The verdict can come back as soon as user space reads the
event, and it is stored on the cached connection, so an insert
afterwards overwrote it with no verdict at all.

Skip non-first IP fragments in the packet layer. `get_key_from_nb_v4`
and `_v6` read the two bytes after the IP header as ports, which in a
later fragment are payload, producing a key for a connection that does
not exist. They now return `Ok(None)` for a non-zero fragment offset
(following the IPv6 Fragment header chain to find it) and the packet
layer permits: the first fragment carries the decision.

Fix the `IOQueue` pop status check. `NtStatus::try_from(ptr as u32)`
compared only the low 32 bits of the returned pointer, so an entry
whose address happened to end in one of the three status values was
reported as an error and leaked. Compare the whole value. Make
`wait_and_pop` wait in one-second steps so a reader that entered just
as `rundown` ran notices the queue is gone, and run the queue down once
more from `Drop` to free an entry pushed in that same window.

Stop building slices from the IRP system buffer unconditionally: a
zero-length read or write has no system buffer, and `from_raw_parts_mut`
on null is UB even for length zero. `DeviceControlRequest::complete`
now reports the bytes actually written rather than the whole output
buffer, and returns the status directly, since the IRP belongs to the
I/O manager once completed and must not be read afterwards.

Correct the NDIS and WFP struct layouts. `NET_BUFFER_LIST` and
`NET_BUFFER` were missing the `DECLSPEC_ALIGN(16)` on their reserved
arrays and `NET_BUFFER` was missing the padding of the
`NET_BUFFER_HEADER` union, which shifted `DataPhysicalAddress` and
`SharedMemoryInfo`. In `FwpsIncomingMetadataValues`,
`local_redirect_target_pid` was `u64` and the vSwitch fields were
guessed as padding, shifting everything after
`current_l2_metadata_values`. Pin all of it down with compile-time
offset assertions against the WDK 10.0.26100 headers.

Do not leak a callout when its filter cannot be added: the callout
registration is not part of the WFP transaction, so an abort does not
undo it. `register_callout` unregisters it, `commit` keeps every
callout it managed to register so the teardown can find them, and the
three transaction paths restore the recorded filter ids when the
transaction aborts and WFP keeps the filters it already had.

Drop the ShutdownRequest IOCTL from the driver and from
`kext_interface`. Shutdown is the write command; the IOCTL was a second
entry point into the same teardown.
Cap the packet cache and the event queue. Both grew without bound, so
a user-space agent that stopped reading could make the driver hold
every pended packet and every event until the machine ran out of pool.
The packet cache now holds at most 32768 packets and the event queue
16384 events, and the callouts check `Device::is_backlogged` before
capturing anything: when either is full they fail closed (block,
without copying the packet) instead of pending one more.

Move the two pushes into `Device::queue_packet`, which all three
capture sites now call. If the event queue filled up between the
backlog check and the push, a packet-layer copy is taken back out of
the cache and dropped; a pended ALE operation is left to expire
instead, since completing it inside the classify that pended it would
reenter the classify.

Add `wdk::worker::PeriodicWorker`: a system thread that calls a
routine at a fixed interval at PASSIVE_LEVEL and can be stopped and
waited for, with `PsCreateSystemThread` and `ZwClose` declared in
`ffi` (windows-sys types the start routine without its context
argument and links `ZwClose` against ntdll). The device starts one for
maintenance, runs it every second, and stops it first thing in
`shutdown`, from where on the teardown resolves the remaining packets.

Maintenance blocks the packets that have waited 30 seconds for a
verdict, so a connect does not hang forever on an agent that stopped
answering. A pended connection that is still undecided is moved to
`Failed` first, with a compare-exchange (`Connection::replace_verdict`)
rather than a store, so a verdict that arrived in the meantime still
wins; the reinjection then classifies the connection again and the
cached verdict decides it.

Split `inject_packet(packet, blocked)` into `inject_permitted_packet`
and `inject_blocked_packet` over a shared `reinject_ale`. The boolean
read backwards at every call site, and the old body's early `Ok(())`
for a blocked packet-layer copy looked like it also covered the ALE
case, which must always be reinjected.

Stop calling `handle_alloc_error` from the global allocator: it
bugchecks on every failed allocation, including the fallible ones.
`alloc` returns null as `GlobalAlloc` requires and lets the caller
decide. It also honours the requested alignment now, asking for a
cache-aligned allocation above the pool's 16 bytes and refusing
anything over 64, rather than returning memory the caller cannot use.

`PrintMemoryStats` reports the new counters: packets expired without a
verdict, events queued, and events dropped because the queue was full.

Also drop the unused `CalloutData::can_set_action` wrapper, and record
a TODO in `clone_packet` about large send offload, where the copy
loses `TcpLargeSendNetBufferListInfo` and the recomputed TCP checksum
is not the pseudo-header seed LSO expects.
Replace the RCU port design with a reader-writer spin lock per port.
Every read used to clone an `Arc`, and every write copied the whole
port array, published the copy and pushed the old one onto an
`MpscQueue` to be freed later, once its reader count had been zero for
a second. `PortConnections<T>` now holds a `Vec<Entry<T>>` that
readers share and a writer changes in place, so an add or a removal
costs one lock instead of a copy of the port plus deferred-free
bookkeeping. Each port has its own lock, so traffic on one port never
waits for another. `rcu_port.rs` and `mpsc_queue.rs` are gone, as are
the unlinked-array counters in the memory stats.

Because nothing may outlive the lock, the cache hands connections to a
closure rather than returning them: `end_v4(key, f)`,
`with_connection_v4`, `end_all_on_port_v4(port, f)` and
`clean_ended_connections(on_stale_v4, on_stale_v6)`. Every caller now
builds its event inside the closure, while the connection is still
alive, which is what lets a removed connection be freed right away
instead of on a later pass. The closures must not reach into the cache
again — a writer waiting on the same port would deadlock — and `read`
and `write` say so.

Keep each connection's identity inline next to the pointer to it:
`Entry` stores a `Connection::CompactKey` (local and remote address
and remote port; the cache slot already fixes the protocol and the
local port), so a lookup scans contiguous memory and only dereferences
the match. `Connection::equals` and `get_key` are replaced by
`compact_key` and `compact_key_of`, which also reject a key of the
wrong IP version instead of silently failing to match. The redirect
check still has to consider every connection on the port, so it is now
only reached for packets whose remote port is the DNS or the tunnel
port, the only two places a redirect goes.

Bound stale eviction with `MAX_STALE_EVICTIONS` instead of a `Vec`
capacity. The old code evicted while `capacity() > len()` on a vector
built `with_capacity(100)`, so the limit lived in an allocation; it is
now a constant of 100 per IP version.

Check `alloc_zeroed` for null in `alloc_port_array`. Each of the four
port arrays is a single large allocation and the old code wrapped
whatever came back in a `Box`, so a failure became a null `self.tcp_v4`
and a crash on the first packet. `ConnectionCache::new` returns a
`Result` and `Device::new` propagates it.

Also move the `dbg!` in `ale_resource_monitor` ahead of the cache
call, so a discarded or released port is logged even when no
connection was open on it.
Complete the IRP from `ReadRequest::complete` and
`WriteRequest::complete`, which consume the request and return the
status for the dispatch routine, instead of every caller setting an
outcome, reading it back with `get_status` and completing the IRP
itself. The outcome setters are renamed `set_success`,
`set_end_of_file` and `set_timeout`; a read starts out successful
with zero bytes, so a dispatch that finds no device still completes,
and a timeout reports no bytes rather than whatever `fill_index` had
reached. A write clears `Information` on construction.

Read `Value::value_type` as the raw `FWP_DATA_TYPE` that WFP writes
there: a type outside the `ValueType` enum made the value an invalid
discriminant. `ValueType::from_raw` maps it and
`CalloutData::get_value_type` returns an `Option`, which the two
endpoint closure callouts match on.

Return the transport header size only when the layer provides it.
`get_transport_header_size` now checks
FWPS_METADATA_FIELD_TRANSPORT_HEADER_SIZE and `create_packet_list`
gives up when the field is missing. Unlike the IP header there is no
safe fallback, since TCP options make the size vary, and retreating
by a wrong amount clones a shifted packet.

Check the injection handle the packet actually goes through.
`inject_packet_list` tested `packet_inject_handle_v4` even for an
IPv6 packet, so an invalid v6 handle got through the guard and into
the inject call.

Free only the net buffer list that was passed in. `free_net_buffer`
walked the `Next` chain, but the lists it frees come from
`wrap_packet_in_nbl`, which builds exactly one; anything chained
behind it belongs to whoever chained it.

Delete `spin_lock.rs`. Nothing used it, and `KSpinLock::create`
passed its own null pointer to `KeInitializeSpinLock` and then locked
on that.

Tighten the two hand-written `Sync` impls. `IOQueue<T>` now requires
`T: Send`, since an entry is pushed on one thread and popped on
another, and `rw_spin_lock::Mutex<T>` requires `T: Sync` as well,
because its readers hold `&T` on several threads at once.

Build the connection events on the connection. `end_info` and
`update_info` on the `Connection` trait replace eight copies of the
same twelve-argument call across the endpoint closures, the resource
monitor, `GetConnectionsUpdate` and `CleanEndedConnections`, and one
generic `log_connection` replaces the two near-identical walk
closures in `PrintMemoryStats`.

Report the attempt the reauthorized batch actually went out on: the
counter was zeroed before the message read it, so it always said
zero. Also note in the packet layer that the loop sets the classify
action per net buffer and the last one wins, which is correct because
everything in one classify belongs to one flow.

Record what is still open. The device is not opened exclusive, so two
agents would split the event stream between them, and closing a
handle leaves that agent's pended packets to the 30 s timeout and
keeps the cached verdicts. The read also blocks its thread in
`device.read` until an event arrives, so it cannot be cancelled; it
wants a cancel-safe queue.
# Conflicts:
#	CLAUDE.md
#	Justfile
#	driver/src/connection_cache.rs
#	driver/src/device.rs
#	driver/src/entry.rs
#	driver/src/id_cache.rs
#	driver/src/lib.rs
The scenario tests in mock/sim/tests ran twice: once from source in
pr-checks.yml (sim-linux) and once against the installed wheels in
zfsim-wheels.yml. Drop the source run and keep the wheel run as the
only CI execution of mock/sim/tests, and extend it to pushes to main
so the wheels and scenarios are also checked outside pull requests.

Build the Go test agent in the wheels job and point ZF_KEXT_TESTER at
it, since the installed package cannot locate kext_tester in the
source tree.

Drop the separate check-kext-interface job from build.yml: the Windows
test job in pr-checks.yml already compiles kext_interface via `go
test`, so only the full `go vet` is left to add there.
Keep queued events as plain values rather than as serialized buffers.
Every event used to be built into an `Info`, a `Vec` allocated in the
callout that produced it, pushed as a boxed queue entry, and copied
again into the read buffer. The queue now holds an `Event` enum of
fixed-size structs -- the connection, end and update events, and the
update-end marker -- and they are serialized straight into the read
buffer when user space reads them. Log lines stay an `Info`: they are
rare and their length varies.

A packet event no longer carries the packet. It records the payload
length and `Event::write` copies the payload out of the packet cache
at read time, under its read lock. A packet that expired in the
meantime is written as zeros of the announced length, so the stream
keeps its framing.

Serialize through a `Sink`: either a `Vec` that grows, as the log
lines and the tests use, or a `Writer` over the read buffer, which
leaves out the first `sent` bytes of the event and stops at the end
of the buffer. That is what lets an event larger than one read be
sent across several, so `PendingEvent` now keeps the event itself and
how many of its bytes went out, instead of the serialized rest. A
test checks that writing an event in chunks of every size produces
the same bytes as writing it in one go. `write_event` drops an event
that stops short of its own `serialized_size` with room left in the
buffer, rather than stashing it and stalling every read after it.

Replace the KQUEUE behind `IOQueue` with a `VecDeque` reserved to
`max_len` and a synchronization event. The old queue boxed a
`LIST_ENTRY` per entry, so a push allocated at DISPATCH_LEVEL on
every packet sent to user space, and a failed allocation was not
expressible. Room for all entries is taken once, in `IOQueue::new`,
which returns a `Result` that `Device::new` propagates before it
registers anything; push and pop never allocate after that. A reader
waits on the event in one-second steps and passes it on when more
entries are left, since it is auto-reset and only the push into an
empty queue signals it. With the per-entry box gone, `MAX_EVENTS`
goes from 16384 to 32768.

Hand connections to the closures without collecting their results.
`end_all_on_port_v4`/`_v6` and `clean_ended_connections` returned a
`Vec` of whatever the closure built, which the caller then pushed;
the closures now push into the event queue themselves, under the
port's lock. Pushing never waits, and a full queue drops the event.
Transport injection needed three separately allocated pieces to stay
alive until its completion routine ran: the packet copy inside the
NET_BUFFER_LIST, the control data blob and the remote address. They
were kept alive by boxing a TransportInjectContext and handing that to
WFP as the completion context, which means an extra allocation (plus
the Vec behind the packet copy) per injected packet.

Make the packet copy a single PacketBuffer allocation that can carry a
prefix in front of the packet bytes, and put the remote address, the
control data length and the control data itself into that prefix. The
start of the allocation is then the completion context: no context is
allocated for an injection, and freeing the list frees everything the
send referenced.

Network injections, which only need the list, hand over the same
allocation with an empty prefix instead of boxing the NetBufferList.

from_injected rebuilds the buffer from the completion context, taking
the packet length from the MDL, and refuses to free a list whose MDL
does not start right after the prefix, since freeing with a wrong
layout would corrupt the pool.
# Conflicts:
#	driver/src/device.rs
#	driver/src/lib.rs
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